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:
+68
-23
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user