From 5c0d2d87ddaa8f2e77ea3ae5501053210827516f Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:15:03 +0000 Subject: [PATCH] Carry side-effect and cancellation semantics in exec results Why: F05 prep - exec.Result could not distinguish canceled runs from retryable failures or express side-effect uncertainty, so callers inferred outcomes from error text. Evidence: exec/adapters/runtime tests pass (verify-u3a.json); canceled and side-effect population cases added. Impact: zero-value results keep legacy meaning; no interface change. Refs: feature/agent-stack-audit-remediation-b1 U3a --- adapters/native/backend.go | 91 +++++++++--- adapters/native/result_fidelity_test.go | 178 ++++++++++++++++++++++++ exec/backend.go | 24 ++++ exec/result_fidelity_test.go | 30 ++++ runtime/loop.go | 47 +++++-- 5 files changed, 333 insertions(+), 37 deletions(-) create mode 100644 adapters/native/result_fidelity_test.go create mode 100644 exec/result_fidelity_test.go diff --git a/adapters/native/backend.go b/adapters/native/backend.go index fb3938e..ca4f573 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -149,10 +149,10 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, profile = exec.ToolProfileWorkspace } if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning { - return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil + return preflightFail("unknown tool profile: " + profile), nil } if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") { - return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil + return preflightFail("agent-native backend requires api base/key (or injected stream)"), nil } if b.opts.Ask != nil { ctx = mcp.WithAsk(ctx, b.opts.Ask) @@ -168,7 +168,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, model = strings.TrimSpace(req.Model) } if model == "" { - return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil + return preflightFail("agent-native backend requires model"), nil } workDir := req.WorkDir @@ -177,7 +177,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, // 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。 tmp, err := os.MkdirTemp("", "agent-native-run-*") if err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } workDir = tmp } else if !filepath.IsAbs(workDir) { @@ -185,7 +185,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, workDir = filepath.Join(wd, workDir) } if err := os.MkdirAll(workDir, 0o755); err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } if strings.TrimSpace(req.Instructions) != "" { _ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644) @@ -207,7 +207,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, } for _, name := range req.Skills { if !indexSet[name] { - return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil + return preflightFail("declared skill not installed: " + name), nil } } @@ -216,13 +216,13 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, // reject in preflight rather than pretend the write-set holds. if len(writable) > 0 && profile == exec.ToolProfileWorkspace { if b.opts.Exec != nil { - return &exec.Result{Success: false, ErrorReason: "writable write-set cannot be enforced when exec tools are enabled"}, nil + return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil } if b.opts.MCP != nil { - return &exec.Result{Success: false, ErrorReason: "writable write-set cannot be enforced when MCP tools are enabled"}, nil + return preflightFail("writable write-set cannot be enforced when MCP tools are enabled"), nil } if len(b.opts.HostTools) > 0 { - return &exec.Result{Success: false, ErrorReason: "writable write-set cannot be enforced when host tools are enabled"}, nil + return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil } } var reg *tools.Registry @@ -252,7 +252,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, switch { case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready(): if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } default: b.opts.MCP.StartBackground(ctx, reg) @@ -261,12 +261,12 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, subagent.Register(reg, b.opts.Subagent) for _, tool := range b.opts.HostTools { if reg.Has(tool.Name()) { - return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil + return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil } reg.Register(tool) } if err := reg.Restrict(req.ToolAllowlist); err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } } // Scope-keyed transcript: empty scope keeps the legacy fixed path. @@ -276,7 +276,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, b.rotateIfPoisoned(workDir, transcript) store, err := session.Open(transcript) if err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } stream := b.opts.Stream if stream == nil { @@ -284,7 +284,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, stream = client.Stream } if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + return preflightFail(err.Error()), nil } b.maybeCompact(ctx, store, stream, model) @@ -331,7 +331,11 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, onProcess := req.OnEvent runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess) if err != nil { - return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil + // Persist/session transport failure after the run started: outcome uncertain. + return &exec.Result{ + Success: false, ErrorReason: err.Error(), Logs: logs.String(), + Unknown: true, SideEffects: exec.SideEffectPossible, + }, nil } _ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644) @@ -348,11 +352,17 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, authorized := make([]reasoningTarget, 0, len(req.Targets)) for _, target := range req.Targets { if !pathInWriteSet(writable, target) { - return &exec.Result{Success: false, ErrorReason: "target is outside the declared writable set: " + target, Logs: logs.String()}, nil + return &exec.Result{ + Success: false, ErrorReason: "target is outside the declared writable set: " + target, + Logs: logs.String(), SideEffects: sideEffectsForTools(runRes.ToolCalls), + }, nil } path, resolveErr := tools.Resolve(workDir, filepath.FromSlash(target)) if resolveErr != nil { - return &exec.Result{Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String()}, nil + return &exec.Result{ + Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String(), + SideEffects: sideEffectsForTools(runRes.ToolCalls), + }, nil } skip := false if b2, readErr := os.ReadFile(path); readErr == nil && len(strings.TrimSpace(string(b2))) > 0 { @@ -372,17 +382,31 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, if runRes.Err != nil { reason := runRes.Err.Error() b.noteFailure(workDir, reason) + canceled := runRes.Canceled || runRes.StopReason == llm.StopAborted + retryable := runRes.Retryable && !canceled assessment := exec.AssessStepFailure(exec.StepFailureSignals{ Err: runRes.Err, WorkDir: workDir, - Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()}, + Result: &exec.Result{ErrorReason: reason, Retryable: 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 + "work_dir", workDir, + "canceled", canceled) + res := &exec.Result{ + Success: false, + ErrorReason: reason, + Logs: logs.String(), + Retryable: retryable, + Canceled: canceled, + SideEffects: exec.SideEffectPossible, + } + if canceled { + // Aborted/canceled: never retryable; side effects may already exist. + res.Retryable = false + res.SideEffects = exec.SideEffectPossible + } else if runRes.ToolCalls == 0 { + res.SideEffects = exec.SideEffectNone } return res, nil } @@ -390,7 +414,14 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil) if matErr != nil { - return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil + return &exec.Result{ + Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String(), + SideEffects: exec.SideEffectPossible, + }, nil + } + side := sideEffectsForTools(runRes.ToolCalls) + if len(patches) > 0 { + side = exec.SideEffectPossible } return &exec.Result{ Success: true, @@ -398,6 +429,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, Logs: logs.String(), PromptTokens: runRes.Usage.PromptTokens, CompletionTokens: runRes.Usage.CompletionTokens, + SideEffects: side, }, nil } @@ -558,6 +590,19 @@ type errString string func (e errString) Error() string { return string(e) } +// preflightFail is a definitive rejection before any tool dispatch. +func preflightFail(reason string) *exec.Result { + return &exec.Result{Success: false, ErrorReason: reason, SideEffects: exec.SideEffectNone} +} + +// sideEffectsForTools maps dispatched tool count to a SideEffects qualifier. +func sideEffectsForTools(toolCalls int) exec.SideEffects { + if toolCalls > 0 { + return exec.SideEffectPossible + } + return exec.SideEffectNone +} + // resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/ // 迭代上限/非法请求——重试必须换新会话而非续跑)。 var resumeUnsafeMarkers = []string{ diff --git a/adapters/native/result_fidelity_test.go b/adapters/native/result_fidelity_test.go new file mode 100644 index 0000000..565065e --- /dev/null +++ b/adapters/native/result_fidelity_test.go @@ -0,0 +1,178 @@ +package native + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "agent-native/exec" + "agent-native/runtime/llm" +) + +// TestExecuteCanceledIsNotRetryable: aborted/canceled runs carry Canceled +// semantics with Retryable=false and SideEffects=possible. +func TestExecuteCanceledIsNotRetryable(t *testing.T) { + dir := t.TempDir() + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 1) + go func() { + <-ctx.Done() + ch <- llm.Event{ + Kind: llm.KindError, + Err: ctx.Err(), + Retryable: true, // provider may mark abort retryable; adapter must not. + Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopAborted}, + } + close(ch) + }() + return ch + }, + }, + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + res, err := b.Execute(ctx, exec.Request{ + WorkDir: dir, Prompt: "cancel me", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if res.Success { + t.Fatalf("canceled run must not succeed: %+v", res) + } + if !res.Canceled { + t.Fatalf("want Canceled=true, got %+v", res) + } + if res.Retryable { + t.Fatalf("canceled must not be Retryable: %+v", res) + } + if res.EffectiveSideEffects() != exec.SideEffectPossible { + t.Fatalf("canceled SideEffects = %q, want possible", res.EffectiveSideEffects()) + } +} + +// TestExecuteToolFailureSideEffectsPossible: failure after tools may have run +// reports SideEffects=possible. +func TestExecuteToolFailureSideEffectsPossible(t *testing.T) { + dir := t.TempDir() + var call int + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 2) + go func() { + defer close(ch) + call++ + if call == 1 { + msg := llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "write_file", Arguments: `{"path":"scratch.md","content":"side effect"}`, + }}} + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg} + return + } + ch <- llm.Event{ + Kind: llm.KindError, + Err: errString("provider failed after tools"), + Message: llm.Message{Role: llm.RoleAssistant, Content: "boom", StopReason: llm.StopError}, + } + }() + return ch + }, + }, + }) + res, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "write then fail", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if res.Success || res.Canceled { + t.Fatalf("want definitive failure after tools, got %+v", res) + } + if res.SideEffects != exec.SideEffectPossible { + t.Fatalf("SideEffects = %q, want possible", res.SideEffects) + } + if _, statErr := os.Stat(filepath.Join(dir, "scratch.md")); statErr != nil { + t.Fatalf("tool side effect missing: %v", statErr) + } +} + +// TestExecutePreflightSideEffectsNone: validation rejection before tools +// reports SideEffects=none. +func TestExecutePreflightSideEffectsNone(t *testing.T) { + cases := []struct { + name string + opts Options + req exec.Request + want string + }{ + { + name: "missing-config", + opts: Options{}, + req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}, + want: "requires", + }, + { + name: "bad-profile", + opts: Options{ModelOptions: ModelOptions{Stream: script(), Model: "fake"}}, + req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"}, + want: "tool profile", + }, + { + name: "missing-model", + opts: Options{ModelOptions: ModelOptions{APIBase: "http://x", APIKey: "k"}}, + req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}, + want: "model", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := New(tc.opts).Execute(context.Background(), tc.req) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if res.Success || res.Canceled || res.Unknown || res.Retryable { + t.Fatalf("preflight must be definitive non-retry failure: %+v", res) + } + if res.SideEffects != exec.SideEffectNone { + t.Fatalf("SideEffects = %q, want none", res.SideEffects) + } + if !strings.Contains(res.ErrorReason, tc.want) { + t.Fatalf("ErrorReason = %q, want substring %q", res.ErrorReason, tc.want) + } + }) + } +} + +// TestExecuteSuccessSideEffectsReflectTools: success after tool dispatch +// reports SideEffects=possible; patches also imply possible. +func TestExecuteSuccessSideEffectsReflectTools(t *testing.T) { + dir := t.TempDir() + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"ok"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, + }) + res, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "write", Targets: []string{"output.md"}, + }) + if err != nil || !res.Success { + t.Fatalf("want success, got err=%v res=%+v", err, res) + } + if res.SideEffects != exec.SideEffectPossible { + t.Fatalf("SideEffects = %q, want possible", res.SideEffects) + } +} diff --git a/exec/backend.go b/exec/backend.go index 2f65efa..2bff501 100644 --- a/exec/backend.go +++ b/exec/backend.go @@ -76,10 +76,34 @@ type Result struct { ErrorReason string `json:"error_reason"` // Retryable marks a provider-transient failure that may receive a // fresh step invocation. Permanent missing deliverables stay false. + // Canceled and Unknown are never retryable; do not set Retryable with them. Retryable bool `json:"retryable,omitempty"` // PromptTokens and CompletionTokens are set by pi-native when the provider reports usage. PromptTokens int `json:"prompt_tokens,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"` + + // ---- terminal fidelity (optional; zero-value = legacy behavior) ---- + + // Canceled marks an aborted/canceled run. Distinct from Retryable: + // a canceled action must not be auto-retried (Retryable must stay false). + Canceled bool `json:"canceled,omitempty"` + // Unknown marks that execution reached the provider/tool stage and the + // terminal outcome could not be determined (side effects may exist). + // Unknown is not a retryable failure. + Unknown bool `json:"unknown,omitempty"` + // SideEffects qualifies external side-effect risk for retry/fallback. + // Empty means SideEffectPossible — the safe legacy reading. Set + // SideEffectNone only when no tool dispatch (or equivalent) is provable. + SideEffects SideEffects `json:"side_effects,omitempty"` +} + +// EffectiveSideEffects returns the side-effect qualifier for retry decisions. +// An empty SideEffects value means SideEffectPossible (backward compatible). +func (r *Result) EffectiveSideEffects() SideEffects { + if r == nil || r.SideEffects == "" { + return SideEffectPossible + } + return r.SideEffects } type Backend interface { diff --git a/exec/result_fidelity_test.go b/exec/result_fidelity_test.go new file mode 100644 index 0000000..5b8d56d --- /dev/null +++ b/exec/result_fidelity_test.go @@ -0,0 +1,30 @@ +package exec_test + +import ( + "testing" + + "agent-native/exec" +) + +// TestResultZeroValueKeepsLegacyMeaning pins backward compatibility: +// empty SideEffects reads as possible; Canceled/Unknown stay false. +func TestResultZeroValueKeepsLegacyMeaning(t *testing.T) { + var r exec.Result + if r.Success || r.Retryable || r.Canceled || r.Unknown { + t.Fatalf("zero Result must keep legacy false flags, got %+v", r) + } + if r.SideEffects != "" { + t.Fatalf("zero SideEffects must be empty (legacy), got %q", r.SideEffects) + } + if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible { + t.Fatalf("EffectiveSideEffects() = %q, want %q", got, exec.SideEffectPossible) + } + r.SideEffects = exec.SideEffectNone + if got := r.EffectiveSideEffects(); got != exec.SideEffectNone { + t.Fatalf("explicit none: EffectiveSideEffects() = %q", got) + } + r.SideEffects = exec.SideEffectPossible + if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible { + t.Fatalf("explicit possible: EffectiveSideEffects() = %q", got) + } +} diff --git a/runtime/loop.go b/runtime/loop.go index 8caab97..02925a9 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -3,6 +3,7 @@ package runtime import ( "context" "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -198,6 +199,13 @@ type Result struct { Notice string Err error Retryable bool + // Canceled is true when the run stopped due to abort/cancel (StopAborted + // or context cancellation). Canceled runs are never retryable. + Canceled bool + // ToolCalls is the number of tool invocations dispatched during the run. + // Adapters use it to set SideEffects (0 before any dispatch => none where + // the failure is otherwise definitive; otherwise possible). + ToolCalls int } // Run executes the turn loop until the model stops, follow-ups drain, or the turn cap is hit. @@ -332,19 +340,28 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { 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 + return Result{ + Text: last.Content, Messages: messages, StopReason: llm.StopAborted, + Err: ctx.Err(), Canceled: true, ToolCalls: toolCalls, + }, nil } if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" { - return budgetStop(cfg, last, messages, usage, reason), nil + return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil } emit(cfg, Event{Type: "turn_start"}) emit(cfg, Event{Type: "message_start"}) request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge) end, errEv := consume(ctx, cfg, request) if errEv != nil { - cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable) + canceled := end.Message.StopReason == llm.StopAborted || + errors.Is(errEv, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) + retryable := end.Retryable && !canceled + cfg.logger().Error("runtime turn failed", errEv, "retryable", retryable, "canceled", canceled) emitUsage(cfg, usage) - return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil + return Result{ + Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, + Err: errEv, Retryable: retryable, Canceled: canceled, ToolCalls: toolCalls, + }, nil } last = end.Message usage.PromptTokens += end.Usage.PromptTokens @@ -360,7 +377,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { cfg.logger().Error("runtime terminal stream failed", end.Err) emitUsage(cfg, usage) return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, - Usage: usage, Err: end.Err, Retryable: false}, nil + Usage: usage, Err: end.Err, Retryable: false, ToolCalls: toolCalls}, nil } if err := persist(cfg, last); err != nil { return Result{}, err @@ -375,7 +392,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { if err != nil { return Result{}, err } - return budgetStop(cfg, last, messages, usage, reason), nil + return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil } if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls { reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls) @@ -384,7 +401,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { if err != nil { return Result{}, err } - return budgetStop(cfg, last, messages, usage, reason), nil + return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil } toolCalls += len(last.ToolCalls) if cfg.RepeatGuard { @@ -418,7 +435,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { if lengthCap > 0 && lengthContinues >= lengthCap { errCap := errString("length continuation exceeded") cfg.logger().Error("runtime length continuation exceeded", errCap) - return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil + return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, + Usage: usage, Err: errCap, ToolCalls: toolCalls}, nil } lengthContinues++ lengthNudge = true @@ -454,9 +472,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } continue } - return settle(cfg, last, messages, usage, decision.Notice), nil + return settle(cfg, last, messages, usage, toolCalls, decision.Notice), nil } - return settle(cfg, last, messages, usage, ""), nil + return settle(cfg, last, messages, usage, toolCalls, ""), nil } errCap := errString("max turns exceeded") cfg.logger().Error("runtime turn cap hit", errCap) @@ -466,6 +484,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { StopReason: llm.StopError, Usage: usage, Err: errCap, + ToolCalls: toolCalls, }, nil } @@ -841,11 +860,11 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { return msg } -func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result { +func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, notice string) Result { emit(cfg, Event{Type: "agent_end", Message: last}) emit(cfg, Event{Type: "agent_settled", Message: last}) emitUsage(cfg, usage) - return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice} + return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice, ToolCalls: toolCalls} } func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message { @@ -1200,12 +1219,12 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st } // budgetStop 以预算超限收尾:发事件、留日志、按 StopError 返回(不可重试)。 -func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, reason string) Result { +func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, reason string) Result { emit(cfg, Event{Type: "budget_exceeded", Text: reason}) emitUsage(cfg, usage) err := errString(reason) cfg.logger().Error("runtime budget exceeded", err) - return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err} + return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err, ToolCalls: toolCalls} } type errString string