[agent/codex:gpt-5.4-mini] Update the code against the AX (Agent Experience) design pri... #14

Merged
Virgil merged 1 commit from agent/update-the-code-against-the-ax--agent-ex into dev 2026-03-30 13:36:41 +00:00
5 changed files with 109 additions and 16 deletions

View file

@ -63,6 +63,9 @@ type Err struct {
Msg string // human-readable description
Err error // underlying cause (optional)
Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED")
Retryable bool // whether the caller can retry the operation
RetryAfter *time.Duration // optional retry delay hint
NextAction string // suggested next step when not retryable
}
```
@ -115,6 +118,9 @@ log(LevelInfo, "[INF]", ...)
| if any value implements `error`:
| extract Op -> append "op" key if not already present
| extract FormatStackTrace -> append "stack" key if not already present
| extract recovery hints -> append "retryable",
| "retry_after_seconds",
| "next_action" if not already present
+-- format key-value pairs:
| string values -> %q (quoted, injection-safe)
| other values -> %v

View file

@ -5,7 +5,7 @@ description: Structured logging and error handling for Core applications
# go-log
`forge.lthn.ai/core/go-log` provides structured logging and contextual error
`dappco.re/go/core/log` provides structured logging and contextual error
handling for Go applications built on the Core framework. It is a small,
zero-dependency library (only `testify` at test time) that replaces ad-hoc
`fmt.Println` / `log.Printf` calls with level-filtered, key-value structured
@ -15,7 +15,7 @@ stack.
## Quick Start
```go
import "forge.lthn.ai/core/go-log"
import "dappco.re/go/core/log"
// Use the package-level default logger straight away
log.SetLevel(log.LevelDebug)
@ -54,6 +54,10 @@ log.Op(err) // "user.Save"
log.Root(err) // the original underlyingErr
log.StackTrace(err) // ["user.Save", "db.Connect"]
log.FormatStackTrace(err) // "user.Save -> db.Connect"
// Recovery hints are also available when the error carries them
log.IsRetryable(err) // false unless a wrapped Err marks it retryable
log.RecoveryAction(err) // "retry with backoff" when provided
```
### Combined Log-and-Return
@ -70,7 +74,7 @@ if err != nil {
| File | Purpose |
|------|---------|
| `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper |
| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) |
| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, and recovery-aware variants), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`, recovery hints), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) |
| `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging |
| `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers |
@ -89,7 +93,7 @@ code.
## Module Path
```
forge.lthn.ai/core/go-log
dappco.re/go/core/log
```
Requires **Go 1.26+** (uses `iter.Seq` from the standard library).

View file

@ -7,7 +7,6 @@ package log
import (
"errors"
"fmt"
"iter"
"strings"
"time"
@ -30,20 +29,34 @@ type Err struct {
// Error implements the error interface.
func (e *Err) Error() string {
var prefix string
if e.Op != "" {
prefix = e.Op + ": "
if e == nil {
return ""
}
if e.Err != nil {
body := e.Msg
if body == "" {
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
body = "[" + e.Code + "]"
}
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
} else if e.Code != "" {
body += " [" + e.Code + "]"
}
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
if e.Err != nil {
if body != "" {
body += ": " + e.Err.Error()
} else {
body = e.Err.Error()
}
}
return fmt.Sprintf("%s%s", prefix, e.Msg)
if e.Op != "" {
if body != "" {
return e.Op + ": " + body
}
return e.Op
}
return body
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
@ -65,6 +78,8 @@ func E(op, msg string, err error) error {
}
// EWithRecovery creates a new Err with operation context and recovery metadata.
//
// return log.EWithRecovery("api.Call", "temporary failure", err, true, &retryAfter, "retry with backoff")
func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error {
recoveryErr := &Err{
Op: op,
@ -95,6 +110,8 @@ func Wrap(err error, op, msg string) error {
}
// WrapWithRecovery wraps an error with operation context and explicit recovery metadata.
//
// return log.WrapWithRecovery(err, "api.Call", "temporary failure", true, &retryAfter, "retry with backoff")
func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
if err == nil {
return nil
@ -132,6 +149,8 @@ func WrapCode(err error, code, op, msg string) error {
}
// WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata.
//
// return log.WrapCodeWithRecovery(err, "TEMPORARY_UNAVAILABLE", "api.Call", "temporary failure", true, &retryAfter, "retry with backoff")
func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
if code == "" {
code = ErrCode(err)
@ -163,6 +182,8 @@ func NewCode(code, msg string) error {
}
// NewCodeWithRecovery creates a coded error with recovery metadata.
//
// var ErrTemporary = log.NewCodeWithRecovery("TEMPORARY_UNAVAILABLE", "temporary failure", true, &retryAfter, "retry with backoff")
func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
return &Err{
Msg: msg,
@ -187,6 +208,8 @@ func inheritRecovery(dst *Err, err error) {
}
// RetryAfter returns the first retry-after hint from an error chain, if present.
//
// retryAfter, ok := log.RetryAfter(err)
func RetryAfter(err error) (*time.Duration, bool) {
var wrapped *Err
if As(err, &wrapped) {
@ -198,6 +221,8 @@ func RetryAfter(err error) (*time.Duration, bool) {
}
// IsRetryable reports whether the error chain contains a retryable Err.
//
// if log.IsRetryable(err) { /* retry the operation */ }
func IsRetryable(err error) bool {
var wrapped *Err
if As(err, &wrapped) {
@ -207,6 +232,8 @@ func IsRetryable(err error) bool {
}
// RecoveryAction returns the first next action from an error chain.
//
// next := log.RecoveryAction(err)
func RecoveryAction(err error) string {
var wrapped *Err
if As(err, &wrapped) {

View file

@ -45,6 +45,20 @@ func TestErr_Error_EmptyOp_Good(t *testing.T) {
assert.Equal(t, "wrapped: underlying", err.Error())
}
func TestErr_Error_EmptyMsg_Good(t *testing.T) {
err := &Err{Op: "api.Call", Code: "TIMEOUT"}
assert.Equal(t, "api.Call: [TIMEOUT]", err.Error())
err = &Err{Op: "api.Call", Err: errors.New("underlying")}
assert.Equal(t, "api.Call: underlying", err.Error())
err = &Err{Op: "api.Call", Code: "TIMEOUT", Err: errors.New("underlying")}
assert.Equal(t, "api.Call: [TIMEOUT]: underlying", err.Error())
err = &Err{Op: "api.Call"}
assert.Equal(t, "api.Call", err.Error())
}
func TestErr_Unwrap_Good(t *testing.T) {
underlying := errors.New("underlying error")
err := &Err{Op: "test", Msg: "wrapped", Err: underlying}

View file

@ -8,15 +8,20 @@
`type Err struct`
Structured error wrapper that carries operation context, a human-readable message, an optional wrapped cause, and an optional machine-readable code.
It can also carry agent-facing recovery metadata that survives wrapping and is
surfaced automatically in structured logs.
Fields:
- `Op string`: operation name. When non-empty, `Error` prefixes the formatted message with `Op + ": "`.
- `Msg string`: human-readable message stored on the error and returned by `Message`.
- `Err error`: wrapped cause returned by `Unwrap`.
- `Code string`: optional machine-readable code. When non-empty, `Error` includes it in square brackets.
- `Retryable bool`: whether the caller can safely retry the operation.
- `RetryAfter *time.Duration`: optional retry delay hint when `Retryable` is true.
- `NextAction string`: suggested next step when the error is not directly retryable.
Methods:
- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, or `"{Op}: {Msg} [{Code}]: {Err}"`.
- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, `"{Op}: {Msg} [{Code}]: {Err}"`, or a cleanly collapsed form such as `"{Op}"` when no message, code, or cause is present.
- `func (e *Err) Unwrap() error`: returns `e.Err`.
### Level
@ -32,7 +37,7 @@ Methods:
Concurrency-safe structured logger. `New` clones the configured redaction keys, stores the configured level and writer, and initializes all style hooks to identity functions.
Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as `<nil>`, and any `error` value in `keyvals` can cause `op` and `stack` fields to be appended automatically if those keys were not already supplied.
Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as `<nil>`, and any `error` value in `keyvals` can cause `op`, `stack`, `retryable`, `retry_after_seconds`, and `next_action` fields to be appended automatically if those keys were not already supplied.
Fields:
- `StyleTimestamp func(string) string`: transforms the rendered `HH:MM:SS` timestamp before it is written.
@ -103,6 +108,12 @@ Returns the package-level default logger. The package initializes it with `New(O
Returns `&Err{Op: op, Msg: msg, Err: err}` as an `error`. It always returns a non-nil error value, even when `err` is nil.
### EWithRecovery
`func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error`
Returns `&Err{Op: op, Msg: msg, Err: err}` with explicit recovery metadata
attached to the new error value.
### ErrCode
`func ErrCode(err error) string`
@ -133,6 +144,16 @@ Thin wrapper around `errors.Is`.
Thin wrapper around `errors.Join`.
### IsRetryable
`func IsRetryable(err error) bool`
Returns whether the first matching `*Err` in the chain is marked retryable.
### NewCodeWithRecovery
`func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
Returns `&Err{Msg: msg, Code: code}` with recovery metadata attached.
### LogError
`func LogError(err error, op, msg string) error`
@ -163,6 +184,16 @@ Constructs a logger from `opts`. It prefers a rotating writer only when `opts.Ro
Returns `&Err{Msg: msg, Code: code}` as an `error`.
### RecoveryAction
`func RecoveryAction(err error) string`
Returns the first next-action hint from the error chain.
### RetryAfter
`func RetryAfter(err error) (*time.Duration, bool)`
Returns the first retry-after hint from the error chain, if present.
### NewError
`func NewError(text string) error`
@ -218,7 +249,18 @@ Calls `Default().Warn(msg, keyvals...)`.
If `err` is nil, returns nil. Otherwise returns a new `*Err` containing `op`, `msg`, and `err`. If the wrapped error chain already contains an `*Err` with a non-empty `Code`, the new wrapper copies that code.
### WrapCodeWithRecovery
`func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
Returns nil only when both `err` is nil and `code` is empty. Otherwise it
returns a wrapped `*Err` with explicit recovery metadata attached.
### WrapCode
`func WrapCode(err error, code, op, msg string) error`
Returns nil only when both `err` is nil and `code` is empty. In every other case it returns `&Err{Op: op, Msg: msg, Err: err, Code: code}` as an `error`.
### WrapWithRecovery
`func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
Returns nil when `err` is nil. Otherwise it returns a wrapped `*Err` with explicit recovery metadata attached.