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
This commit is contained in:
2026-10-21 16:15:03 +00:00
parent afbb567301
commit 5c0d2d87dd
5 changed files with 333 additions and 37 deletions
+68 -23
View File
@@ -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{
+178
View File
@@ -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)
}
}
+24
View File
@@ -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 {
+30
View File
@@ -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)
}
}
+33 -14
View File
@@ -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