Align execution errors with the shared catalog and add optional logs.

Contract codes now use the agent.* values, and runtime can emit structured logs without changing results when no logger is configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-29 14:24:11 +00:00
co-authored by Cursor
parent 57c049bd38
commit e65c6ba604
16 changed files with 268 additions and 76 deletions
+6
View File
@@ -1,3 +1,9 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
+2 -2
View File
@@ -21,8 +21,8 @@
| 逻辑 ID | 路径 | 角色 |
|---|---|---|
| development-guidelines | `deps/development-guidelines` | development-only,规范文本 |
| error-code-system | `deps/error-code-system` | build-required(接入后) |
| logging-system | `deps/logging-system` | build-required(接入后) |
| error-code-system | `deps/error-code-system` | build-required,规范码目录 |
| logging-system | `deps/logging-system` | build-required,可选日志器 |
克隆:
+21 -3
View File
@@ -9,9 +9,9 @@
| 层 | 职责 | 依赖边界 |
|---|---|---|
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 |
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置,也不导入错误码库 |
| exec | 执行端口:Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 实现 contract;不导入 Core/Squad |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 不导入 Core/Squad;可选使用日志库,nil 日志器不改变结果 |
| adapters | PI Runner transport / typed-result-v1;native:自研内核 runtime → exec.Backend(规范后端名 agent-native,含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 |
| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 |
@@ -37,6 +37,22 @@
- 失败副作用显式分类(none/possible);possible 禁止无条件重放。
- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。
- Workspace 路径授权 ≠ OS 沙箱。
- `contract.ErrorCode` 取值是 `agent.*` 规范码。`ErrorCode()` / `ErrorFields()` 供日志库识别,本包不导入错误码库。
## 依赖子模块
| 路径 | 作用 |
|---|---|
| `deps/development-guidelines` | 开发规范,不参与编译 |
| `deps/error-code-system` | 规范码目录。`go.mod` 的 `replace` 指向这里 |
| `deps/logging-system` | 结构化日志。runtime 与 native 适配器可选使用 |
版本以本仓 gitlink 为准。`scripts/submodule-consistency.sh` 核对 gitlink 与检出的 HEAD。
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
## CodeGraph
@@ -58,9 +74,11 @@ go test ./...
```
require agent-native v0.0.0
replace agent-native => ../Agent-Native
replace agent-native => ./deps/agent-native
```
本仓自己的 `go.mod` 则把错误码库和日志库替换到 `./deps/`。消费方若同时依赖这三仓,必须在消费方 `go.mod` 里写自己的 `replace`:依赖模块内部的 `replace` 不会在上层构建中生效。
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
## CI
+28
View File
@@ -17,6 +17,8 @@ import (
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
"logging-system/logsys"
)
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
@@ -28,6 +30,10 @@ type Options struct {
Timeout time.Duration
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
Stream llm.StreamFunc
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
ExecutionID string
}
// Backend 以自研内核实现 exec.Backend。
@@ -135,6 +141,14 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: runRes.Err,
WorkDir: workDir,
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()},
})
b.logger().Error("native step failure classified", runRes.Err,
"failure_class", assessment.Class.String(),
"work_dir", workDir)
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
@@ -170,6 +184,8 @@ func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.S
Stream: stream,
Session: store,
MaxTurns: b.opts.MaxTurns,
Log: b.logger(),
ExecutionID: b.opts.ExecutionID,
OnEvent: func(ev runtime.Event) {
logs.WriteString(ev.Type)
if ev.Tool != "" {
@@ -224,10 +240,22 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
if _, err := os.Stat(path); err != nil {
return
}
b.logger().Warn("rotating poisoned session", "work_dir", workDir)
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
}
func (b *Backend) logger() *logsys.Logger {
if b == nil || b.opts.Log == nil {
return logsys.Nop()
}
log := b.opts.Log.Module("native")
if b.opts.ExecutionID == "" {
return log
}
return log.With("execution_id", b.opts.ExecutionID)
}
// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束。
func systemPrompt(req exec.Request, profile string) string {
var b strings.Builder
+5 -5
View File
@@ -91,17 +91,17 @@ func safeLegacyReason(reason string) string {
case r == "":
return "remote pi-agent reported task failure"
case strings.EqualFold(r, "prompt_empty"):
return "PROMPT_EMPTY"
return "agent.prompt_empty"
case strings.Contains(strings.ToLower(r), "unauthorized"):
return "UNAUTHORIZED"
return "agent.unauthorized"
case strings.Contains(strings.ToLower(r), "timeout"):
return "TIMEOUT"
return "agent.timeout"
default:
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
return "RUN_FAILED"
return "agent.run_failed"
}
if len(r) > 64 {
return "RUN_FAILED"
return "agent.run_failed"
}
return r
}
+12 -10
View File
@@ -20,20 +20,22 @@ const (
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
// 对外错误码与 Error-Code-System 的 agent 域一致。
// CANCELLED 并入 agent.canceled,MISSING_TARGET 并入 agent.missing_deliverable。
var allowedErrorCodes = map[string]struct{}{
"PROMPT_EMPTY": {},
"UNAUTHORIZED": {},
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"MISSING_TARGET": {},
"RUN_FAILED": {},
"agent.prompt_empty": {},
"agent.unauthorized": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
"agent.missing_deliverable": {},
"agent.run_failed": {},
}
var retryableErrorCodes = map[string]struct{}{
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
}
type ResultV1Member struct {
+4 -4
View File
@@ -63,13 +63,13 @@ func TestResultV1RejectsMissingMemberBody(t *testing.T) {
}
func TestTypedRetryableAllowlist(t *testing.T) {
ok := BuildResultV1("r", "error", true, "TRANSIENT", nil)
ok := BuildResultV1("r", "error", true, "agent.transient", nil)
if !TypedRetryable(ok) {
t.Fatal("TRANSIENT retryable")
t.Fatal("agent.transient retryable")
}
denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil)
denied := BuildResultV1("r", "error", true, "agent.prompt_empty", nil)
if TypedRetryable(denied) {
t.Fatal("PROMPT_EMPTY must not retry")
t.Fatal("agent.prompt_empty must not retry")
}
}
+17 -17
View File
@@ -8,35 +8,35 @@ export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024;
/** Tail window for execution.log patches; full log remains on shared volume. */
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]);
const RETRYABLE_CODES = new Set(["agent.timeout", "agent.canceled", "agent.transient"]);
const ALLOWED_CODES = new Set([
"PROMPT_EMPTY",
"UNAUTHORIZED",
"TIMEOUT",
"CANCELLED",
"TRANSIENT",
"MISSING_TARGET",
"RUN_FAILED",
"agent.prompt_empty",
"agent.unauthorized",
"agent.timeout",
"agent.canceled",
"agent.transient",
"agent.missing_deliverable",
"agent.run_failed",
]);
export function typedErrorCode(payload) {
const raw = String(payload?.fallback_reason || payload?.error || "");
const t = raw.toLowerCase();
if (t.includes("prompt_empty")) return "PROMPT_EMPTY";
if (t.includes("unauthorized")) return "UNAUTHORIZED";
if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT";
if (t.includes("cancel")) return "CANCELLED";
if (t.includes("prompt_empty")) return "agent.prompt_empty";
if (t.includes("unauthorized")) return "agent.unauthorized";
if (t.includes("timeout") || t.includes("timed out")) return "agent.timeout";
if (t.includes("cancel")) return "agent.canceled";
if (
t.includes("connection") ||
t.includes("terminated") ||
t.includes("econnreset") ||
t.includes("socket hang")
) {
return "TRANSIENT";
return "agent.transient";
}
if (t.includes("missing target")) return "MISSING_TARGET";
if (payload?.retryable) return "TRANSIENT";
if (!payload?.ok) return "RUN_FAILED";
if (t.includes("missing target")) return "agent.missing_deliverable";
if (payload?.retryable) return "agent.transient";
if (!payload?.ok) return "agent.run_failed";
return "";
}
@@ -117,7 +117,7 @@ export function withResultV1(payload) {
run_id: String(out.run_id || "unknown").slice(0, 128),
outcome: ok ? "success" : "error",
retryable,
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED",
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "agent.run_failed",
members,
content_digest: membersDigest(members),
};
+34
View File
@@ -0,0 +1,34 @@
package catalogcheck
import (
"testing"
"agent-native/contract"
"error-code-system/errcode"
)
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
func TestContractCodesMatchCatalog(t *testing.T) {
pairs := []struct {
got contract.ErrorCode
want errcode.Code
}{
{contract.ErrCanceled, errcode.AgentCanceled},
{contract.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
{contract.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
{contract.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
{contract.ErrProviderRejected, errcode.AgentProviderRejected},
{contract.ErrToolRejected, errcode.AgentToolRejected},
{contract.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
{contract.ErrMissingCapability, errcode.AgentMissingCapability},
{contract.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
{contract.ErrEmptyOutput, errcode.AgentEmptyOutput},
{contract.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
{contract.ErrInternal, errcode.AgentInternal},
}
for _, pair := range pairs {
if string(pair.got) != string(pair.want) {
t.Errorf("contract %q != catalog %q", pair.got, pair.want)
}
}
}
+43 -13
View File
@@ -174,18 +174,18 @@ const (
type ErrorCode string
const (
ErrCanceled ErrorCode = "canceled"
ErrDeadlineExceeded ErrorCode = "deadline_exceeded"
ErrBudgetExceeded ErrorCode = "budget_exceeded"
ErrProviderUnavailable ErrorCode = "provider_unavailable"
ErrProviderRejected ErrorCode = "provider_rejected"
ErrToolRejected ErrorCode = "tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace"
ErrMissingCapability ErrorCode = "missing_capability"
ErrMissingDeliverable ErrorCode = "missing_deliverable"
ErrEmptyOutput ErrorCode = "empty_output"
ErrUnknownOutcome ErrorCode = "unknown_outcome"
ErrInternal ErrorCode = "internal"
ErrCanceled ErrorCode = "agent.canceled"
ErrDeadlineExceeded ErrorCode = "agent.deadline_exceeded"
ErrBudgetExceeded ErrorCode = "agent.budget_exceeded"
ErrProviderUnavailable ErrorCode = "agent.provider_unavailable"
ErrProviderRejected ErrorCode = "agent.provider_rejected"
ErrToolRejected ErrorCode = "agent.tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "agent.path_outside_workspace"
ErrMissingCapability ErrorCode = "agent.missing_capability"
ErrMissingDeliverable ErrorCode = "agent.missing_deliverable"
ErrEmptyOutput ErrorCode = "agent.empty_output"
ErrUnknownOutcome ErrorCode = "agent.unknown_outcome"
ErrInternal ErrorCode = "agent.internal"
)
// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界
@@ -197,7 +197,37 @@ type Error struct {
SideEffects SideEffects `json:"side_effects"`
}
func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason }
func (e *Error) Error() string {
if e == nil {
return ""
}
return string(e.Code) + ": " + e.Reason
}
// ErrorCode 返回规范码字符串。日志库用这个方法识别错误,因此 contract 不导入错误码模块。
func (e *Error) ErrorCode() string {
if e == nil {
return ""
}
return string(e.Code)
}
// ErrorFields 返回可写入日志的字段。执行标识由调用方随日志字段另传,不放进本结构。
func (e *Error) ErrorFields() map[string]any {
if e == nil {
return nil
}
fields := map[string]any{
"retryable": e.Retryable,
}
if e.Reason != "" {
fields["reason"] = e.Reason
}
if e.SideEffects != "" {
fields["side_effects"] = string(e.SideEffects)
}
return fields
}
// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。
type Usage struct {
+20
View File
@@ -41,3 +41,23 @@ func TestFakeCancelSemantics(t *testing.T) {
t.Fatal("backend must observe ctx cancellation before returning")
}
}
func TestErrorDuckTypeForLogs(t *testing.T) {
e := &contract.Error{
Code: contract.ErrProviderUnavailable,
Reason: "down",
Retryable: true,
SideEffects: contract.SideEffectNone,
}
if e.ErrorCode() != "agent.provider_unavailable" {
t.Fatal(e.ErrorCode())
}
fields := e.ErrorFields()
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
t.Fatalf("%v", fields)
}
var nilErr *contract.Error
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
t.Fatal("nil error")
}
}
Vendored Submodule
+1
Submodule deps/error-code-system added at b67696d393
Vendored Submodule
+1
Submodule deps/logging-system added at 7f5972007b
+9
View File
@@ -1,3 +1,12 @@
module agent-native
go 1.27
require (
error-code-system v0.0.0
logging-system v0.0.0
)
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
+22 -1
View File
@@ -9,6 +9,8 @@ import (
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
"logging-system/logsys"
)
const (
@@ -39,6 +41,10 @@ type Config struct {
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
OnEvent func(Event)
// Log 可选。nil 时丢弃日志,不改变循环结果。
Log *logsys.Logger
// ExecutionID 只用于日志字段,不参与控制流。
ExecutionID string
}
// Result is the final assistant text and the transcript produced by one run.
@@ -87,11 +93,13 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
var usage llm.Usage
for turn := 0; turn < cfg.MaxTurns; turn++ {
if ctx.Err() != nil {
cfg.logger().Error("runtime turn aborted", ctx.Err())
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopAborted, Err: ctx.Err()}, nil
}
emit(cfg, Event{Type: "turn_start"})
end, errEv := consume(ctx, cfg, messages)
if errEv != nil {
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
}
last = end.Message
@@ -136,15 +144,28 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
emit(cfg, Event{Type: "agent_end", Message: last})
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil
}
errCap := errString("max turns exceeded")
cfg.logger().Error("runtime turn cap hit", errCap)
return Result{
Text: last.Content,
Messages: messages,
StopReason: llm.StopError,
Usage: usage,
Err: errString("max turns exceeded"),
Err: errCap,
}, nil
}
func (cfg Config) logger() *logsys.Logger {
if cfg.Log == nil {
return logsys.Nop()
}
log := cfg.Log.Module("runtime")
if cfg.ExecutionID == "" {
return log
}
return log.With("execution_id", cfg.ExecutionID)
}
func systemText(cfg Config) string {
var b strings.Builder
b.WriteString(cfg.SystemPrompt)
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# 核对本仓 gitlink 与已检出子模块 HEAD 是否一致。
# 嵌套副本是否与消费方自己的 deps 相同,由消费方仓库的同名脚本比较。
set -eu
cd "$(dirname "$0")/.."
fail=0
for path in deps/development-guidelines deps/error-code-system deps/logging-system; do
if [ ! -e "$path/.git" ]; then
echo "missing submodule checkout: $path" >&2
fail=1
continue
fi
recorded=$(git rev-parse ":$path")
actual=$(git -C "$path" rev-parse HEAD)
if [ "$recorded" != "$actual" ]; then
echo "gitlink mismatch: $path recorded=$recorded HEAD=$actual" >&2
fail=1
else
echo "ok $path $recorded"
fi
done
exit "$fail"