mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
* refactor(go): adopt modern stdlib idioms across kernel and desktop Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/ usestdlibvars fixers plus hand-fixes for what they could not rewrite: slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor, maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int. Net -549 lines with no behavior change. Three loops in session.go stay index-based: slices.Backward yields element copies, so the fixer's rewrite silently dropped writes to DecisionReceipts and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49 slices.Backward sites were audited for that shape; only these three write through the index, and they carry a nolint with the reason. CaptureAfter's scan loop had an empty body and never ran any logic, so the fixer could not express it. Drop it and the accessor it was the only caller of. omitzero is deliberately untouched: turning omitempty into omitzero changes the JSON written for nested structs, which is a wire-format change to session files and the extension protocol. * build(lint): gate the modern-idiom linters Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both modules are now clean for modernize, intrange, copyloopvar, and usestdlibvars, so enable them rather than leaving the sweep as a one-off. omitzero stays disabled with the reason inline; usetesting, perfsprint, and errorlint still have residue and land separately.
4071 lines
152 KiB
Go
4071 lines
152 KiB
Go
package agent
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"mvdan.cc/sh/v3/syntax"
|
||
|
||
"reasonix/internal/ablation"
|
||
"reasonix/internal/capability"
|
||
"reasonix/internal/checkpoint"
|
||
"reasonix/internal/diff"
|
||
"reasonix/internal/event"
|
||
"reasonix/internal/evidence"
|
||
"reasonix/internal/extension/dispatch"
|
||
"reasonix/internal/instruction"
|
||
"reasonix/internal/jobs"
|
||
"reasonix/internal/memory"
|
||
"reasonix/internal/nilutil"
|
||
"reasonix/internal/permission"
|
||
"reasonix/internal/planmode"
|
||
"reasonix/internal/provider"
|
||
"reasonix/internal/sandbox"
|
||
"reasonix/internal/shellparse"
|
||
"reasonix/internal/tool"
|
||
"reasonix/internal/workspacelease"
|
||
)
|
||
|
||
// maxToolOutputBytes caps a single tool result before it goes into the model's
|
||
// context. ~32KB is roughly 8K tokens — enough for a full file read or a busy
|
||
// grep, while preventing one accidental "read this 5 MB log" from blowing the
|
||
// window before the next compaction runs.
|
||
const maxToolOutputBytes = 32 * 1024
|
||
|
||
const maxEmptyFinalBlocks = 3
|
||
|
||
// maxStreamRecoveries is the number of body-phase stream retries after the
|
||
// initial sampling attempt (Codex-aligned default: 1 + 5 = 6 attempts total).
|
||
const maxStreamRecoveries = 5
|
||
const maxSamplingAttempts = maxStreamRecoveries + 1
|
||
const maxExecutorHandoffNudges = 1
|
||
|
||
const defaultReasoningByteLimit = 128 * 1024
|
||
|
||
const finishReasonClientReasoningLimit = "client_reasoning_limit"
|
||
|
||
var errReasoningByteLimitExceeded = errors.New("reasoning output exceeded client byte limit")
|
||
|
||
// DeliveryRuntimeMarker is the delivery-mode contract block appended to user
|
||
// turns (withTurnPreferences). Exported as the single source of truth for the
|
||
// byte-exact suffix strip in preview derivation and for cross-package tests;
|
||
// its text is cache-frozen — changing it breaks steer replay matching and the
|
||
// prefix stability of every live delivery session.
|
||
const DeliveryRuntimeMarker = `<delivery-runtime>
|
||
This session is in delivery-first mode. Before any state-changing tool call,
|
||
establish concrete, verifiable acceptance criteria with todo_write. After the
|
||
change, inspect the result, run relevant verification, and sign off each step
|
||
with complete_step citing the successful verification command. The host enforces
|
||
these gates and will reject mutation or finalization when evidence is missing.
|
||
</delivery-runtime>`
|
||
|
||
// Renderer redraws the assistant's final-answer text as styled output. It is
|
||
// applied only after a turn's text stream completes, so the user sees raw
|
||
// markdown stream live, then a single redraw replaces it with formatted
|
||
// output. The renderer is intentionally interface-shaped so the agent stays
|
||
// independent of the cli's markdown library choice. Consumed by TextSink.
|
||
type Renderer interface {
|
||
Render(text string) string
|
||
}
|
||
|
||
// Asker puts structured multiple-choice questions to the user and blocks for the
|
||
// answers. The agent consults it for the `ask` tool. It is interface-shaped so
|
||
// the agent stays independent of the frontend; a nil asker means no interactive
|
||
// user (headless runs), where `ask` returns a "decide for yourself" result. The
|
||
// interactive frontends wire the controller in as the Asker.
|
||
type Asker interface {
|
||
Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)
|
||
}
|
||
|
||
// callContextKey carries the executing tool call's identity into Execute.
|
||
type callContextKey struct{}
|
||
type parentSessionContextKey struct{}
|
||
type subagentDepthContextKey struct{}
|
||
type userImagesContextKey struct{}
|
||
|
||
// callContext is the per-call context a tool can read. parentID is the call being
|
||
// executed and sink is the agent's event sink (the `task` tool uses both to nest
|
||
// a sub-agent's events under this call); asker lets the `ask` tool reach the user.
|
||
type callContext struct {
|
||
parentID string
|
||
sink event.Sink
|
||
asker Asker
|
||
planMode bool
|
||
}
|
||
|
||
// withCallContext stamps ctx with the executing call's ID, the agent's sink, and
|
||
// the asker. executeOne sets this before every Execute; `task` reads it (via
|
||
// CallContext) to nest sub-agent events, and `ask` reads the asker to prompt.
|
||
// The plan-mode flag is mirrored onto the leaf planmode key so tools that must
|
||
// not import this package (for example internal/tool/builtin) can still read it.
|
||
func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
|
||
ctx = planmode.WithActive(ctx, planMode)
|
||
return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: sink, asker: asker, planMode: planMode})
|
||
}
|
||
|
||
// WithToolCallContext stamps ctx as a host-initiated top-level tool call.
|
||
// Normal model-selected tools receive this context from executeOne; controller
|
||
// entry points that deliberately invoke the same tool machinery (for example a
|
||
// user typing /<subagent-skill>) use this exported wrapper so nested sub-agent
|
||
// activity still reaches the parent event stream and plan-mode policy remains
|
||
// visible to the invoked runner.
|
||
func WithToolCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
|
||
return withCallContext(ctx, parentID, sink, asker, planMode)
|
||
}
|
||
|
||
// CallContext returns the executing call's ID, the agent's sink, and the asker,
|
||
// if the context was set by an agent's executeOne. ok is false for a plain
|
||
// context (headless tool tests, calls made outside the run loop).
|
||
func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker Asker, ok bool) {
|
||
cc, ok := ctx.Value(callContextKey{}).(callContext)
|
||
if !ok {
|
||
return "", nil, nil, false
|
||
}
|
||
return cc.parentID, cc.sink, cc.asker, true
|
||
}
|
||
|
||
// PlanModeFromContext reports whether the tool call is executing during the
|
||
// plan-first workflow. Tools may use it for phase-specific behavior, but it is
|
||
// not a permission or read-only boundary.
|
||
func PlanModeFromContext(ctx context.Context) bool {
|
||
cc, ok := ctx.Value(callContextKey{}).(callContext)
|
||
return ok && cc.planMode
|
||
}
|
||
|
||
// WithParentSession stamps the active parent session ID onto a turn context so
|
||
// persisted sub-agents can record and enforce their owning conversation.
|
||
func WithParentSession(ctx context.Context, parentSession string) context.Context {
|
||
return context.WithValue(ctx, parentSessionContextKey{}, strings.TrimSpace(parentSession))
|
||
}
|
||
|
||
// ParentSession returns the active parent session ID carried by a turn context.
|
||
func ParentSession(ctx context.Context) string {
|
||
parentSession, _ := ctx.Value(parentSessionContextKey{}).(string)
|
||
return strings.TrimSpace(parentSession)
|
||
}
|
||
|
||
// WithSubagentDepth carries the current subagent depth through nested tool calls.
|
||
// The root agent runs at depth 0; each spawned subagent increments by one.
|
||
func WithSubagentDepth(ctx context.Context, depth int) context.Context {
|
||
if depth < 0 {
|
||
depth = 0
|
||
}
|
||
return context.WithValue(ctx, subagentDepthContextKey{}, depth)
|
||
}
|
||
|
||
// SubagentDepth returns the current subagent depth carried by a turn context.
|
||
func SubagentDepth(ctx context.Context) int {
|
||
depth, _ := ctx.Value(subagentDepthContextKey{}).(int)
|
||
if depth < 0 {
|
||
return 0
|
||
}
|
||
return depth
|
||
}
|
||
|
||
// WithUserImages carries the data URLs of images the user attached to this turn,
|
||
// resolved by the controller (which owns attachments) since the agent must not
|
||
// depend on it. Run embeds them on the user message; the provider sends them only
|
||
// when the model is vision-capable.
|
||
func WithUserImages(ctx context.Context, images []string) context.Context {
|
||
return context.WithValue(ctx, userImagesContextKey{}, images)
|
||
}
|
||
|
||
func userImages(ctx context.Context) []string {
|
||
images, _ := ctx.Value(userImagesContextKey{}).([]string)
|
||
return images
|
||
}
|
||
|
||
// Gate decides, per tool call, whether it may run. The agent consults it at
|
||
// execute time after any explicit planning-phase opt-out. It is interface-shaped so the agent
|
||
// stays independent of the permission package and of how "ask" is resolved
|
||
// (silently in headless runs, interactively in the chat TUI). A nil gate means
|
||
// no gating — every call runs, preserving behaviour for callers that don't wire
|
||
// one in. reason is fed back to the model when allow is false; a non-nil err
|
||
// (e.g. ctx cancelled awaiting approval) is treated as a block for that call.
|
||
type Gate interface {
|
||
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
|
||
}
|
||
|
||
// ExplicitDenyGate exposes the only global permission decision that applies to
|
||
// an already-authorized MCP server. Installing or approving a server is the
|
||
// user's authorization boundary; ordinary ask/fallback posture must not add a
|
||
// second per-call prompt, while explicit deny rules remain authoritative.
|
||
type ExplicitDenyGate interface {
|
||
ExplicitlyDenies(toolName string, args json.RawMessage) bool
|
||
}
|
||
|
||
const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
|
||
|
||
// PlanModeReadOnlyTrustRequest describes a bash command that is safe enough to
|
||
// ask the user to accept as read-only during planning. Command is the concrete
|
||
// attempted command and Prefix is the reusable prefix to trust.
|
||
type PlanModeReadOnlyTrustRequest struct {
|
||
ToolName string
|
||
Command string
|
||
Prefix string
|
||
Args json.RawMessage
|
||
}
|
||
|
||
// PlanModeReadOnlyTrustGate is the legacy Plan bash trust bridge. It remains in
|
||
// the internal API for controller compatibility, but ordinary Plan execution no
|
||
// longer invokes it; bash calls use the normal permission gate.
|
||
type PlanModeReadOnlyTrustGate interface {
|
||
CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error)
|
||
}
|
||
|
||
const DefaultMaxSubagentDepth = 2
|
||
|
||
// NormalizeMaxSubagentDepth applies the public config contract: values below 1
|
||
// preserve the old single-delegation boundary.
|
||
func NormalizeMaxSubagentDepth(depth int) int {
|
||
if depth < 1 {
|
||
return 1
|
||
}
|
||
return depth
|
||
}
|
||
|
||
// ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
|
||
// runs before the call and may block it (block=true; message is the reason fed
|
||
// back to the model); PostToolUse runs after and only surfaces output to the
|
||
// user (it can't block). It is interface-shaped so the agent stays independent
|
||
// of the hook package — a nil hooks field disables hook firing entirely.
|
||
type ToolHooks interface {
|
||
PreToolUse(ctx context.Context, name string, args json.RawMessage) (block bool, message string)
|
||
PostToolUse(ctx context.Context, name string, args json.RawMessage, result string)
|
||
PostToolUseFailure(ctx context.Context, name string, args json.RawMessage, result string, err error)
|
||
// PostLLMCall fires after each model turn completes (streaming finishes)
|
||
// but before reasoning_content is stored. It returns the (possibly
|
||
// translated) reasoning string — the original when no hook is configured.
|
||
// HasPostLLMCall reports whether such a hook exists, so the agent keeps
|
||
// streaming reasoning live when none is wired up.
|
||
PostLLMCall(ctx context.Context, reasoning string, turn int) string
|
||
HasPostLLMCall() bool
|
||
// SubagentStop fires when a `task` sub-agent finishes (foreground). PreCompact
|
||
// fires just before a compaction pass and returns extra summary guidance (its
|
||
// hooks' stdout) to fold into the summary prompt; "" when no hook contributes.
|
||
SubagentStop(ctx context.Context, last string)
|
||
PreCompact(ctx context.Context, trigger string) string
|
||
}
|
||
|
||
// Agent drives a single task: a Provider, a tool Registry, and a Session wired
|
||
// into the main loop.
|
||
type Agent struct {
|
||
prov provider.Provider
|
||
tools *tool.Registry
|
||
session *Session
|
||
sessMu sync.Mutex // guards the session pointer for external Session()/SetSession
|
||
maxSteps int
|
||
maxStepsKey string
|
||
reasoningByteLimit int
|
||
maxOutputTokens int
|
||
// executorHandoffGuard is enabled by Coordinator for the executor agent. The
|
||
// per-turn marker check in Run keeps ordinary single-model turns unaffected.
|
||
executorHandoffGuard bool
|
||
temperature float64
|
||
pricing *provider.Pricing
|
||
usageSource string
|
||
modelRef string
|
||
responseLanguage atomic.Value // string: auto|zh|en
|
||
reasoningLanguage atomic.Value // string: auto|zh|en
|
||
|
||
// sink receives the turn's typed event stream (reasoning/text deltas, tool
|
||
// dispatch/results, usage, notices). The agent no longer formats output
|
||
// itself — a frontend's Sink decides how to render. Never nil; New defaults
|
||
// it to event.Discard.
|
||
sink event.Sink
|
||
|
||
// lastUsage caches the most recent per-turn telemetry the provider reported so
|
||
// the CLI can expose a context gauge without re-scraping the usage line. The
|
||
// run loop writes it while a frontend's status line reads it, so it is atomic.
|
||
lastUsage atomic.Pointer[provider.Usage]
|
||
|
||
// sessCacheHit/sessCacheMiss accumulate cache tokens across every API call
|
||
// this session, so frontends can show the aggregate hit-rate (Σhit/Σ(hit+miss))
|
||
// — a steadier, cost-oriented number than the single-turn rate. They are NOT
|
||
// reset on compaction (compaction only rewrites session.Messages), so the
|
||
// aggregate never craters when the prefix is summarized away. Atomic: the run
|
||
// loop accumulates them while the status line reads them.
|
||
sessCacheHit atomic.Int64
|
||
sessCacheMiss atomic.Int64
|
||
|
||
// lastPrefixShape records the previous provider request's cacheable prefix
|
||
// so usage events can explain prefix churn on the next request.
|
||
lastPrefixShape PrefixShape
|
||
haveLastPrefixShape bool
|
||
|
||
// warnedMissingToolCallReasoning marks one active missing-reasoning incident
|
||
// within this agent. The legacy name is retained because the persisted state
|
||
// predates silent recovery; it now gates one automatic retry rather than a
|
||
// user-visible warning. A healthy tool-call turn clears it. Loop-owned;
|
||
// reset by SetSession.
|
||
warnedMissingToolCallReasoning bool
|
||
// missingReasoningWarnStateChecked avoids a file transaction on every
|
||
// healthy tool-call turn. It resets with the session so a new Agent can
|
||
// continue or confirm an incident persisted by an earlier process.
|
||
missingReasoningWarnStateChecked bool
|
||
// missingReasoningHealthyStreak provides the same three-turn anti-flapping
|
||
// policy when no cross-process state directory is configured.
|
||
missingReasoningHealthyStreak int
|
||
// missingReasoningWarnPendingResolveAt keeps a healthy observation retryable
|
||
// when its state write fails. The next missing turn retries that watermark
|
||
// before consulting the persisted incident and otherwise fails visible.
|
||
missingReasoningWarnPendingResolveAt time.Time
|
||
|
||
// missingReasoningWarnState rate-limits recovery retries across sessions and
|
||
// processes by an opaque provider-configuration fingerprint (#7059). The
|
||
// legacy type/file names preserve the on-disk v2 contract. nil (no dir in
|
||
// Options) keeps in-memory active-incident gating only.
|
||
missingReasoningWarnState *missingReasoningWarnState
|
||
|
||
// planMode enables planning workflow instructions and explicit phase opt-outs.
|
||
// It does not replace the permission or sandbox boundary. The system prompt and
|
||
// tool list never change with the toggle, preserving the provider-cache prefix.
|
||
planMode atomic.Bool
|
||
|
||
// readOnlyExecution is a construction-time defense for planner/research
|
||
// agents. Unlike planMode it is not a collaboration toggle: it remains on
|
||
// for the agent's lifetime and validates proxy calls after resolution.
|
||
readOnlyExecution bool
|
||
|
||
// mutationDependencyBarrier is set for the remainder of a provider tool
|
||
// batch after any mutating call fails or is blocked. executeOne re-checks
|
||
// it after proxy resolution so use_capability cannot bypass the barrier by
|
||
// advertising schema-level ReadOnly()==true. Parallel read-only segments
|
||
// never set it. Cleared at the start of each executeBatch.
|
||
mutationDependencyBarrier atomic.Bool
|
||
|
||
// plannerMCPExecution relaxes the strict read-only MCP boundary for the
|
||
// two-model Planner only: authorized, non-destructive MCP targets may run
|
||
// through use_capability even without readOnlyHint. Ordinary writers, bash,
|
||
// and destructive MCP stay blocked. Strict read-only sub-agents leave this
|
||
// false and still require readOnlyHint.
|
||
plannerMCPExecution bool
|
||
|
||
// gate, when non-nil, is the per-call permission gate for both standard and
|
||
// Plan workflows. nil disables gating entirely.
|
||
gate Gate
|
||
|
||
// extensions, when non-nil, is the frozen Extension Protocol v1 dispatcher
|
||
// for this controller generation. The run loop consults it at the
|
||
// agent-side intercept points (see extensions.go); nil means no v1 runtime
|
||
// packages are installed and every point passes through byte-identically.
|
||
extensions *dispatch.Dispatcher
|
||
|
||
// recoveryGate, when non-nil, is the Auto Guard boundary for Auto mode.
|
||
// Shared by root and sub-agents for the same controller task. nil disables
|
||
// recovery checks (Ask/YOLO, headless without wiring, or feature off).
|
||
recoveryGate RecoveryGate
|
||
// recoveryAgentID labels this agent on recovery cards (empty = root).
|
||
recoveryAgentID string
|
||
// recoveryTaskID isolates recovery state across concurrent top-level tasks.
|
||
// Empty shares the root task bucket.
|
||
recoveryTaskID string
|
||
// recoveryRunSeq gives ordinary (non-goal) runs a collision-free host scope.
|
||
// Goal runs use their stable delivery scope instead.
|
||
recoveryRunSeq atomic.Uint64
|
||
|
||
// planModeReadOnlyTrust is retained for legacy controller wiring. The main
|
||
// Plan execution path no longer consults it.
|
||
planModeReadOnlyTrust PlanModeReadOnlyTrustGate
|
||
|
||
// sandboxEscapeApprover, when non-nil, can ask the user whether one shell
|
||
// command may rerun unconfined after the OS sandbox failed to start.
|
||
sandboxEscapeApprover sandbox.EscapeApprover
|
||
|
||
// configWriteApprover, when non-nil, can ask the user whether a file tool
|
||
// may write a Reasonix-managed config file outside the workspace roots.
|
||
configWriteApprover tool.ConfigWriteApprover
|
||
|
||
// hooks, when non-nil, fires PreToolUse / PostToolUse shell hooks around each
|
||
// tool call. nil disables hook firing.
|
||
hooks ToolHooks
|
||
|
||
// asker, when non-nil, lets the `ask` tool put questions to the user. nil in
|
||
// headless runs (no interactive user). Set via SetAsker.
|
||
asker Asker
|
||
|
||
// onPreEdit, when non-nil, is called with a writer tool's previewed change
|
||
// just before it runs — the seam the checkpoint store uses to snapshot a
|
||
// file's pre-edit content. Only fires for non-ReadOnly tools that implement
|
||
// tool.Previewer (so bash, whose targets are unknowable, is never tracked).
|
||
// Set via SetPreEditHook. Prefer mutationObserver when both are set.
|
||
onPreEdit func(diff.Change)
|
||
|
||
// mutationObserver is the host-side unified file mutation observer. It
|
||
// captures preimages before tools run and after-fingerprints regardless of
|
||
// success/failure. Passed through Options to sub-agents; never changes
|
||
// provider-visible tool schemas or prompts.
|
||
mutationObserver *checkpoint.MutationObserver
|
||
|
||
// jobs, when non-nil, is the session's background-job manager. executeOne
|
||
// stamps it onto each tool call's context so the background tools (bash
|
||
// run_in_background, task run_in_background, bash_output/kill_shell/wait) can
|
||
// reach it. nil leaves those tools to degrade gracefully.
|
||
jobs *jobs.Manager
|
||
|
||
// writeScheduler coordinates parent-agent writes against background
|
||
// subagent write claims. Set on the parent executor only (subagentDepth 0);
|
||
// reservation is taken around Execute so late-loaded MCP/Economy tools are
|
||
// covered without registry wrapping. Provider-visible schemas are unchanged.
|
||
writeScheduler *SubagentScheduler
|
||
// writeWorkspaceRoot is the workspace used to normalize parent write
|
||
// reservations when writeScheduler is set.
|
||
writeWorkspaceRoot string
|
||
|
||
// workspaceLease is shared by every writer-capable agent in one Delivery
|
||
// session. It is acquired lazily on the first mutation and held through the
|
||
// final participating run/background job so verification remains isolated.
|
||
workspaceLease *workspacelease.Owner
|
||
|
||
// steerQueue holds mid-turn user messages queued while the agent is
|
||
// running. Each is consumed once per loop iteration, persisted to the
|
||
// session for history replay, and sent to the model as guidance (not a
|
||
// new task). Cache miss for the next API call is unavoidable but limited
|
||
// to one call — the prefix stays stable otherwise.
|
||
steerMu sync.Mutex
|
||
steerQueue []string
|
||
steerConsumed bool
|
||
// steerRunActive is true while Run is executing. Steer only queues while
|
||
// it is set; once the turn's exit flush has drained the queue, later
|
||
// steers are rejected so the caller can deliver them as a regular turn
|
||
// instead of leaving them in a queue no loop will ever consume.
|
||
steerRunActive bool
|
||
|
||
// evidence is a per-user-turn ledger of host-observed tool receipts. It lets
|
||
// complete_step validate that cited evidence happened before the claim.
|
||
evidence *evidence.Ledger
|
||
|
||
// todoState is the host's canonical task list: the latest successful
|
||
// todo_write with completions applied by complete_step. Unlike the per-turn
|
||
// ledger it survives turn boundaries and compaction (it never rides in the
|
||
// prompt), so the final-answer gate still sees an unfinished plan a later
|
||
// turn would otherwise hide. Rebuilt from the session in SetSession.
|
||
todoMu sync.Mutex
|
||
todoState []evidence.TodoItem
|
||
|
||
// hostAdvanceSeq guarantees unique tool IDs across turns: every
|
||
// emitTodoState call increments it so the frontend always sees a fresh
|
||
// dispatch even when the same panel index is signed off in different turns.
|
||
hostAdvanceSeq atomic.Int64
|
||
|
||
// projectChecks are structured project instructions that complete_step can
|
||
// verify against same-turn bash receipts after a write-backed completion.
|
||
projectChecks []instruction.VerifyCheck
|
||
|
||
// deliveryProfile enables the runtime-enforced delivery contract. The stable
|
||
// profile prompt explains intent; these fields are host state and never enter
|
||
// the provider-cached prefix. deliveryScopeID and deliveryCheckpoint survive
|
||
// turns while a stable delivery scope continues; the per-turn expectations
|
||
// live in perTurnState.
|
||
deliveryProfile bool
|
||
deliveryScopeID string
|
||
deliveryCheckpoint evidence.DeliveryCheckpoint
|
||
|
||
// perTurnState groups the host flags that are valid for exactly one
|
||
// Agent.Run. beginRunTurn zeroes the whole struct in one assignment, so a
|
||
// field added here can never be forgotten in the reset; state that must
|
||
// survive turns stays directly on Agent.
|
||
perTurnState
|
||
|
||
// ablation names the subsystems a benchmark arm switched off. The zero value
|
||
// is the control arm.
|
||
ablation ablation.Set
|
||
|
||
// classifierTaskText is the host-trusted task text for delivery intent
|
||
// classification, set by sub-agent spawners whose Run input carries host
|
||
// framing. Empty means classify the raw input verbatim.
|
||
classifierTaskText string
|
||
|
||
// preserveEvidenceOnce makes the next Run keep the turn evidence ledger
|
||
// instead of resetting it. RunSubAgentWithSession sets it before a
|
||
// review_report completion nudge so the retry can cite the read receipts
|
||
// the subagent already earned; consumed (cleared) by that Run.
|
||
preserveEvidenceOnce bool
|
||
// deliveryRecoveryPending is armed only when this agent exhausts final
|
||
// readiness. An explicit host recovery action can consume it to preserve the
|
||
// failed turn's receipts once; an ordinary user turn still resets evidence.
|
||
deliveryRecoveryPending bool
|
||
|
||
// capabilityLedger tracks require/prefer outcomes for this user turn only.
|
||
// Never serialized into prompts or session state.
|
||
capabilityLedger *capability.Ledger
|
||
// capabilityAudit accumulates non-persisted routing/proxy counters.
|
||
capabilityAudit *capability.Audit
|
||
// lastCapabilityGate tracks prefer-reminder state across final-answer retries.
|
||
capabilityPreferReminded bool
|
||
// capabilityRequireMissSeen / capabilityPreferMissSeen remember that the
|
||
// final gate reported a miss earlier this turn, so a later clean gate is
|
||
// audited as a recovery. Reset per turn in SeedCapabilityRoute.
|
||
capabilityRequireMissSeen bool
|
||
capabilityPreferMissSeen bool
|
||
// pendingReviewWarnings are warn-level findings to surface in the final summary.
|
||
pendingReviewWarnings []string
|
||
|
||
// memQueue, when non-nil, lets the remember/forget tools fold a turn-tail note
|
||
// about a just-made memory change into the next turn, so it applies this
|
||
// session without touching the cache-stable prefix. Set via SetMemoryQueue.
|
||
memQueue memory.Queue
|
||
|
||
// subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
|
||
// caps delegation; when reached, recursive agent/skill tools are excluded.
|
||
subagentDepth int
|
||
maxSubagentDepth int
|
||
|
||
// Context management: when a turn's prompt nears contextWindow, the older
|
||
// middle of the session is summarized away, keeping a token-bounded recent
|
||
// tail verbatim (recentKeep is the message floor) and archiving the originals
|
||
// under archiveDir. compactStuck latches when compaction can't get the prompt
|
||
// under the window (consecutiveCompacts crosses the limit), so auto-compaction
|
||
// pauses instead of looping. softCompactNoticed gates the one-shot soft-ratio
|
||
// notice so it fires once per approach, not every turn.
|
||
contextWindow int
|
||
softCompactRatio float64
|
||
toolResultSnipRatio float64
|
||
compactRatio float64
|
||
compactForceRatio float64
|
||
softCompactNoticed bool
|
||
recentKeep int
|
||
archiveDir string
|
||
keepPolicy KeepPolicy
|
||
compactStuck bool
|
||
consecutiveCompacts int
|
||
// activeTurnCreatedAt identifies the real/synthetic user message that began
|
||
// the currently running turn. Compaction may rewrite older history while a
|
||
// tool loop is active, but it must keep this message and everything after it
|
||
// verbatim so cancellation/crash recovery can retain completed tool pairs.
|
||
activeTurnCreatedAt atomic.Int64
|
||
|
||
// stormSig / stormCount track a run of turns that keep failing or getting
|
||
// blocked the same way so the loop can break a death-spiral. The signature is
|
||
// each call's (tool, error/blocker) in order, NOT (tool, args): a stuck model
|
||
// reliably reworks the arguments cosmetically (a re-worded essay, a reordered
|
||
// object, a different shell command) while the host returns the same refusal or
|
||
// failure every time — keying on args misses the loop entirely. Because errors
|
||
// that embed their subject (e.g. "file not found: /x") differ per target,
|
||
// genuine varied probing does not collapse to one signature. Reset whenever a
|
||
// turn does anything else (a different failure/block shape, or any success).
|
||
// See applyStormBreaker.
|
||
stormSig string
|
||
stormCount int
|
||
|
||
// repeatFailureCounts tracks semantically identical write-like calls that
|
||
// keep failing with the same failure class. Unlike stormSig, successful
|
||
// reads do not blindly clear this state: re-reading a file and then
|
||
// resending the same stale anchor is still zero progress. Stale-anchor
|
||
// records also survive target mutations until Preview proves the anchor is
|
||
// applicable again. Ordinary turns reset the map at Run start; Goal
|
||
// continuations retain it while their stable delivery scope is unchanged.
|
||
repeatFailureCounts map[string]repeatFailureRecord
|
||
repeatFailureScope string
|
||
}
|
||
|
||
type repeatFailureRecord struct {
|
||
count int
|
||
errClass string
|
||
paths []string
|
||
stateRecheck bool
|
||
}
|
||
|
||
// KeepPolicy is a bitmask controlling which messages are preserved beyond the
|
||
// recent tail during compaction.
|
||
type KeepPolicy int
|
||
|
||
const (
|
||
KeepErrors KeepPolicy = 1 << iota
|
||
KeepUserMarked
|
||
)
|
||
|
||
// SetPlanMode toggles the plan-first workflow flag. Ordinary calls still use
|
||
// Permissions/Sandbox; only explicit phase opt-outs are refused. The system
|
||
// prompt and tool schemas stay untouched, while the caller supplies the
|
||
// model-facing Marker in a user turn.
|
||
func (a *Agent) SetPlanMode(v bool) { a.planMode.Store(v) }
|
||
|
||
// SetTools replaces the agent's tool registry. The next API call picks up the
|
||
// new tool schema; tools already cached in the provider prefix are unaffected
|
||
// until the prefix is invalidated. Safe to call between turns.
|
||
func (a *Agent) SetTools(tools *tool.Registry) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.tools = tools
|
||
}
|
||
|
||
// SetReasoningLanguage updates the visible reasoning language preference for
|
||
// subsequent user-role messages emitted by this agent.
|
||
func (a *Agent) SetReasoningLanguage(lang string) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.reasoningLanguage.Store(NormalizeReasoningLanguage(lang))
|
||
}
|
||
|
||
// SetResponseLanguage updates the final-answer language preference for
|
||
// subsequent user-role messages emitted by this agent.
|
||
func (a *Agent) SetResponseLanguage(lang string) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.responseLanguage.Store(NormalizeResponseLanguage(lang))
|
||
}
|
||
|
||
// SetGate installs the per-call permission gate. Used by interactive CLI sessions to swap the
|
||
// headless gate built in setup for an interactive one that prompts the user;
|
||
// nil disables gating. Safe to call before the run loop starts.
|
||
func (a *Agent) SetGate(g Gate) {
|
||
if nilutil.IsNil(g) {
|
||
g = nil
|
||
}
|
||
a.gate = g
|
||
}
|
||
|
||
// SetExtensions installs the extension dispatcher after construction. Boot
|
||
// uses it because sidecars — and therefore the dispatcher — only exist after
|
||
// snapshot assembly, which runs after the agent is built. Safe to call before
|
||
// the run loop starts; nil disables interception.
|
||
func (a *Agent) SetExtensions(d *dispatch.Dispatcher) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.extensions = d
|
||
}
|
||
|
||
// SetRecoveryGate installs Auto Guard. Safe to call before the run loop starts;
|
||
// nil disables its checks.
|
||
func (a *Agent) SetRecoveryGate(g RecoveryGate) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
if nilutil.IsNil(g) {
|
||
g = nil
|
||
}
|
||
a.recoveryGate = g
|
||
}
|
||
|
||
// SetRecoveryIdentity sets the agent/task labels used on recovery cards.
|
||
func (a *Agent) SetRecoveryIdentity(agentID, taskID string) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.recoveryAgentID = strings.TrimSpace(agentID)
|
||
a.recoveryTaskID = strings.TrimSpace(taskID)
|
||
}
|
||
|
||
// RecoveryGate returns the attached Auto Guard (may be nil).
|
||
func (a *Agent) RecoveryGate() RecoveryGate {
|
||
if a == nil {
|
||
return nil
|
||
}
|
||
return a.recoveryGate
|
||
}
|
||
|
||
// SetPlanModeReadOnlyTrustGate retains the legacy confirmation bridge for old
|
||
// controller/session data. Main Plan execution no longer calls it.
|
||
func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
|
||
if nilutil.IsNil(g) {
|
||
g = nil
|
||
}
|
||
a.planModeReadOnlyTrust = g
|
||
}
|
||
|
||
// SetSandboxEscapeApprover installs the optional one-shot approval path used by
|
||
// the bash tool when an enforced OS sandbox fails to start.
|
||
func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
|
||
if nilutil.IsNil(g) {
|
||
g = nil
|
||
}
|
||
a.sandboxEscapeApprover = g
|
||
}
|
||
|
||
// SetConfigWriteApprover installs the optional per-write approval path used by
|
||
// the file tools when a target is a Reasonix-managed config file outside the
|
||
// workspace write roots.
|
||
func (a *Agent) SetConfigWriteApprover(g tool.ConfigWriteApprover) {
|
||
if nilutil.IsNil(g) {
|
||
g = nil
|
||
}
|
||
a.configWriteApprover = g
|
||
}
|
||
|
||
func (a *Agent) withTurnPreferences(input string) string {
|
||
if a == nil {
|
||
return input
|
||
}
|
||
responseLang := "auto"
|
||
if v := a.responseLanguage.Load(); v != nil {
|
||
if s, ok := v.(string); ok {
|
||
responseLang = s
|
||
}
|
||
}
|
||
input = WithResponseLanguage(input, responseLang)
|
||
|
||
lang := "auto"
|
||
if v := a.reasoningLanguage.Load(); v != nil {
|
||
if s, ok := v.(string); ok {
|
||
lang = s
|
||
}
|
||
}
|
||
input = WithReasoningLanguage(input, lang)
|
||
if a.deliveryProfile && !strings.Contains(input, "<delivery-runtime>") {
|
||
input = strings.TrimSpace(input) + "\n\n" + DeliveryRuntimeMarker
|
||
}
|
||
return input
|
||
}
|
||
|
||
// SetAsker installs the asker the `ask` tool uses to question the user.
|
||
// Interactive frontends wire one in; headless runs leave it nil.
|
||
func (a *Agent) SetAsker(as Asker) { a.asker = as }
|
||
|
||
// SetMemoryQueue installs the sink the remember/forget tools use to apply a
|
||
// memory change in the current session. The controller wires itself in.
|
||
func (a *Agent) SetMemoryQueue(q memory.Queue) { a.memQueue = q }
|
||
|
||
// SetPreEditHook installs the pre-edit snapshot hook (see onPreEdit). The
|
||
// controller wires it to its per-session checkpoint store; nil disables capture.
|
||
// Prefer SetMutationObserver for v2 capture (before+after fingerprints).
|
||
func (a *Agent) SetPreEditHook(fn func(diff.Change)) { a.onPreEdit = fn }
|
||
|
||
// SetMutationObserver installs the unified mutation observer. When set, it
|
||
// supersedes onPreEdit for capture and also records after-mutation fingerprints.
|
||
// When a task tool is already registered it inherits the observer for sub-agents.
|
||
func (a *Agent) SetMutationObserver(obs *checkpoint.MutationObserver) {
|
||
a.mutationObserver = obs
|
||
if a.tools == nil || obs == nil {
|
||
return
|
||
}
|
||
if t, ok := a.tools.Get("task"); ok {
|
||
if task, ok := t.(*TaskTool); ok {
|
||
task.WithMutationObserver(obs)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MutationObserver returns the installed observer (may be nil).
|
||
func (a *Agent) MutationObserver() *checkpoint.MutationObserver {
|
||
if a == nil {
|
||
return nil
|
||
}
|
||
return a.mutationObserver
|
||
}
|
||
|
||
// Session returns the agent's current conversation, useful for persistence
|
||
// hooks that need to read the message log between turns. sessMu serialises this
|
||
// pointer read against SetSession, so a frontend (serve's concurrent /history and
|
||
// /new handlers) can't race the swap. The run loop touches a.session directly and
|
||
// only swaps it via SetSession while idle, so its reads need no lock.
|
||
func (a *Agent) Session() *Session {
|
||
a.sessMu.Lock()
|
||
defer a.sessMu.Unlock()
|
||
return a.session
|
||
}
|
||
|
||
// SetSession replaces the agent's conversation wholesale. Used by
|
||
// `reasonix --resume` to load a saved JSONL transcript before the first turn,
|
||
// so the model picks up exactly where it left off. Callers serialise it against a
|
||
// running turn (it only fires while idle); sessMu guards the pointer swap itself.
|
||
func (a *Agent) SetSession(s *Session) {
|
||
a.sessMu.Lock()
|
||
a.session = s
|
||
a.sessMu.Unlock()
|
||
a.sessCacheHit.Store(0)
|
||
a.sessCacheMiss.Store(0)
|
||
a.warnedMissingToolCallReasoning = false
|
||
a.missingReasoningWarnStateChecked = false
|
||
a.missingReasoningHealthyStreak = 0
|
||
a.repeatFailureCounts = nil
|
||
a.repeatFailureScope = ""
|
||
if s != nil {
|
||
a.rebuildTodoState(s.Snapshot())
|
||
}
|
||
}
|
||
|
||
// LastUsage returns the most recent per-turn token telemetry the provider
|
||
// reported (nil if no turn has run yet). The TUI uses it to show a context
|
||
// gauge alongside the prompt; the actual cache decisions still live inside
|
||
// maybeCompact.
|
||
func (a *Agent) LastUsage() *provider.Usage { return a.lastUsage.Load() }
|
||
|
||
// SessionCache returns the cumulative cache hit/miss prompt tokens across every
|
||
// API call this session — the basis for the status line's aggregate hit-rate.
|
||
func (a *Agent) SessionCache() (hit, miss int) {
|
||
return int(a.sessCacheHit.Load()), int(a.sessCacheMiss.Load())
|
||
}
|
||
|
||
// ContextWindow returns the configured context-window size in tokens. 0
|
||
// means compaction is disabled for this agent.
|
||
func (a *Agent) ContextWindow() int { return a.contextWindow }
|
||
|
||
// mid-turn steer marker.
|
||
// MidTurnSteerPrefix marks user messages that were injected mid-turn as
|
||
// guidance (via Steer). The model sees them as instructions; frontends
|
||
// display them as a notice, not a regular user bubble.
|
||
const MidTurnSteerPrefix = "[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]"
|
||
|
||
func midTurnSteerMessage(text string) string {
|
||
return MidTurnSteerPrefix + "\n" + text
|
||
}
|
||
|
||
// SteerText checks whether content is a mid-turn steer message and, if so,
|
||
// returns the original user text without the wrapper prefix. The returned
|
||
// text preserves the user's exact input — it only strips the prefix and the
|
||
// "\n" separator that midTurnSteerMessage inserts between the prefix and the
|
||
// user text; it does not trim spaces so the history replay matches the live
|
||
// Steer event rendering character-for-character.
|
||
//
|
||
// Steers are persisted through withTurnPreferences, which can prepend
|
||
// transient language blocks (for Chinese text even in auto mode) and append
|
||
// the delivery-runtime marker. Both are transport framing, not steer text:
|
||
// leading blocks are skipped before matching the prefix and a trailing
|
||
// marker is cut from the returned text, so replay recognizes steers
|
||
// regardless of the session's language and profile settings.
|
||
func SteerText(content string) (string, bool) {
|
||
s := content
|
||
for {
|
||
if after, found := strings.CutPrefix(s, MidTurnSteerPrefix); found {
|
||
// Strip only the "\n" separator, preserving the user's original text.
|
||
after = strings.TrimPrefix(after, "\n")
|
||
if trimmed, cut := strings.CutSuffix(after, "\n\n"+DeliveryRuntimeMarker); cut {
|
||
after = trimmed
|
||
}
|
||
return after, true
|
||
}
|
||
next, ok := trimLeadingSteerWrapper(s)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
s = next
|
||
}
|
||
}
|
||
|
||
// trimLeadingSteerWrapper removes one leading transient preference block that
|
||
// withTurnPreferences may have placed ahead of the steer prefix. It reports
|
||
// false when content does not start with such a block.
|
||
func trimLeadingSteerWrapper(content string) (string, bool) {
|
||
s := strings.TrimLeft(content, " \t\r\n")
|
||
for _, tag := range []string{"response-language", "reasoning-language"} {
|
||
if !strings.HasPrefix(s, "<"+tag+">") {
|
||
continue
|
||
}
|
||
if rest, ok := trimLeadingTransientBlock(s, tag); ok {
|
||
return rest, true
|
||
}
|
||
}
|
||
return content, false
|
||
}
|
||
|
||
// Steer queues a message for mid-turn injection. It reports whether an active
|
||
// turn accepted the text; on false nothing was queued and the caller must
|
||
// deliver it another way (typically as a new turn). Without the active check,
|
||
// a steer landing in the window between the turn's exit flush and the
|
||
// controller observing running=false would sit in the queue unconsumed and
|
||
// unpersisted — invisible to both the model and history.
|
||
func (a *Agent) Steer(text string) bool {
|
||
a.steerMu.Lock()
|
||
defer a.steerMu.Unlock()
|
||
if !a.steerRunActive {
|
||
return false
|
||
}
|
||
a.steerQueue = append(a.steerQueue, text)
|
||
a.steerConsumed = false
|
||
return true
|
||
}
|
||
|
||
// SteerConsumed returns true when the steer queue became empty after the last consume.
|
||
func (a *Agent) SteerConsumed() bool {
|
||
a.steerMu.Lock()
|
||
defer a.steerMu.Unlock()
|
||
return a.steerConsumed
|
||
}
|
||
|
||
func (a *Agent) consumeSteer() (string, bool) {
|
||
a.steerMu.Lock()
|
||
defer a.steerMu.Unlock()
|
||
if len(a.steerQueue) == 0 {
|
||
return "", false
|
||
}
|
||
t := a.steerQueue[0]
|
||
a.steerQueue = a.steerQueue[1:]
|
||
a.steerConsumed = len(a.steerQueue) == 0
|
||
return t, true
|
||
}
|
||
|
||
// closeSteerIntakeIfIdle atomically closes the normal-completion race between
|
||
// the final queue check and Run returning. A steer accepted before this check
|
||
// keeps the loop alive; one arriving after it is rejected so the host can keep
|
||
// the user's draft and retry it as a regular follow-up.
|
||
func (a *Agent) closeSteerIntakeIfIdle() bool {
|
||
a.steerMu.Lock()
|
||
defer a.steerMu.Unlock()
|
||
if len(a.steerQueue) > 0 {
|
||
return false
|
||
}
|
||
a.steerRunActive = false
|
||
return true
|
||
}
|
||
|
||
// flushSteerQueue ends the turn's steer intake. Guidance that arrived too late
|
||
// to be consumed is persisted for transcript visibility but marked local-only:
|
||
// replaying it to the model on the next unrelated user turn can execute a stale
|
||
// historical task (#7045). An explicit warning keeps the transcript honest
|
||
// without presenting the text as successfully applied guidance (#6238).
|
||
func (a *Agent) flushSteerQueue() {
|
||
a.steerMu.Lock()
|
||
pending := a.steerQueue
|
||
a.steerQueue = nil
|
||
if len(pending) > 0 {
|
||
a.steerConsumed = true
|
||
}
|
||
a.steerRunActive = false
|
||
a.steerMu.Unlock()
|
||
for _, text := range pending {
|
||
a.RecordUnappliedSteer(text)
|
||
}
|
||
}
|
||
|
||
// UnappliedSteerNotice returns the durable warning shown for guidance that was
|
||
// accepted during an abnormal turn exit but never reached a provider request.
|
||
func UnappliedSteerNotice(text string) string {
|
||
return "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:\n" + text
|
||
}
|
||
|
||
// RecordUnappliedSteer stores guidance that could not affect its intended
|
||
// in-flight turn. The orphan-tool sentinel makes older readers drop the record
|
||
// during wire normalization, while current readers use LocalOnly to exclude it
|
||
// before every provider request.
|
||
func (a *Agent) RecordUnappliedSteer(text string) {
|
||
if a == nil || a.session == nil {
|
||
return
|
||
}
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleTool,
|
||
Content: a.withTurnPreferences(midTurnSteerMessage(text)),
|
||
ToolCallID: provider.LocalOnlyToolID,
|
||
Name: provider.LocalOnlyToolName,
|
||
LocalOnly: true,
|
||
})
|
||
a.sink.Emit(event.Event{
|
||
Kind: event.Notice,
|
||
Level: event.LevelWarn,
|
||
Code: event.NoticeCodeUnappliedSteer,
|
||
Text: UnappliedSteerNotice(text),
|
||
})
|
||
}
|
||
|
||
func (a *Agent) steerQueueLen() int {
|
||
a.steerMu.Lock()
|
||
defer a.steerMu.Unlock()
|
||
return len(a.steerQueue)
|
||
}
|
||
|
||
// CompactRatio returns the fraction of the window at which auto-compaction
|
||
// fires (e.g. 0.8). The status line uses it to show headroom to the next compact.
|
||
func (a *Agent) CompactRatio() float64 { return a.compactRatio }
|
||
|
||
// CompactNow runs one compaction pass immediately, regardless of the
|
||
// usage-ratio threshold maybeCompact normally honours. Used by the chat
|
||
// TUI's `/compact` command so the user can reset the prefix before it
|
||
// naturally fills up.
|
||
func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
|
||
return a.compact(ctx, "manual", instructions, true)
|
||
}
|
||
|
||
// Options configures an Agent.
|
||
type Options struct {
|
||
MaxSteps int
|
||
// MaxStepsKey names the explicit runtime control shown when the MaxSteps guard
|
||
// is hit. Empty defaults to the generic max_steps tool/runtime parameter.
|
||
MaxStepsKey string
|
||
// ReasoningByteLimit bounds a single stream's hidden reasoning bytes. Zero
|
||
// uses the default guard; a negative value disables only this client guard.
|
||
// Provider output budgets are a separate protocol/model capability.
|
||
ReasoningByteLimit int
|
||
// MaxOutputTokens overrides the provider's configured/default total output
|
||
// budget. Zero delegates to the provider; a negative value asks optional
|
||
// protocols to omit the budget (Anthropic still requires max_tokens).
|
||
MaxOutputTokens int
|
||
Temperature float64
|
||
Pricing *provider.Pricing // optional, for per-turn cost display
|
||
UsageSource string // optional billable usage source; default executor
|
||
// ModelRef names the canonical "provider/model" ref backing this agent's
|
||
// provider instance. It is attached to emitted Usage events so downstream
|
||
// usage accounting can attribute tokens to the exact model.
|
||
ModelRef string
|
||
|
||
// Gate is the per-call permission gate. nil disables gating.
|
||
Gate Gate
|
||
|
||
// ReadOnlyExecution enables a permanent host-side read-only boundary for
|
||
// planner and research agents. It is intentionally independent of Plan mode
|
||
// so a stale collaboration flag cannot authorize a dynamic writer target.
|
||
ReadOnlyExecution bool
|
||
|
||
// PlannerMCPExecution enables Planner-trusted MCP through use_capability:
|
||
// authorized, non-destructive tools may run without readOnlyHint. Only
|
||
// NewPlannerAgent sets this; strict read-only sub-agents must not.
|
||
PlannerMCPExecution bool
|
||
|
||
// PlanModeReadOnlyTrustGate is retained for legacy controller compatibility.
|
||
// The main Plan execution path no longer invokes it.
|
||
PlanModeReadOnlyTrustGate PlanModeReadOnlyTrustGate
|
||
|
||
// SandboxEscapeApprover confirms a one-shot unconfined shell rerun after an
|
||
// enforced OS sandbox fails. nil keeps fail-closed behavior.
|
||
SandboxEscapeApprover sandbox.EscapeApprover
|
||
|
||
// ConfigWriteApprover confirms file-tool writes to Reasonix-managed config
|
||
// files outside the workspace roots. nil keeps fail-closed behavior.
|
||
ConfigWriteApprover tool.ConfigWriteApprover
|
||
|
||
// Context management. ContextWindow <= 0 disables compaction. Ratios and
|
||
// RecentKeep fall back to defaults when unset.
|
||
ContextWindow int
|
||
SoftCompactRatio float64
|
||
ToolResultSnipRatio float64
|
||
CompactRatio float64
|
||
CompactForceRatio float64
|
||
RecentKeep int
|
||
ArchiveDir string
|
||
KeepPolicy KeepPolicy
|
||
|
||
// Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil
|
||
// disables hook firing.
|
||
Hooks ToolHooks
|
||
|
||
// MissingReasoningWarnStateDir, when non-empty, points at the shared
|
||
// directory where missing tool-call thinking recovery retries are gated by
|
||
// opaque provider-configuration fingerprint (#7059). The field name is kept
|
||
// for source compatibility. Boot always supplies it; direct construction
|
||
// with an empty value keeps in-memory gating.
|
||
MissingReasoningWarnStateDir string
|
||
|
||
// Jobs is the session's background-job manager (nil disables background tools).
|
||
Jobs *jobs.Manager
|
||
|
||
// WriteScheduler is the session-scoped subagent concurrency/write-claim
|
||
// controller. When set on the parent executor, write-capable tools reserve
|
||
// paths for the duration of Execute so background writers cannot TOCTOU
|
||
// race parent writes. Subagents leave this nil (or depth > 0 skips it).
|
||
WriteScheduler *SubagentScheduler
|
||
// WriteWorkspaceRoot normalizes parent write reservations.
|
||
WriteWorkspaceRoot string
|
||
|
||
// WorkspaceLease serializes Delivery mutations across sessions that target
|
||
// the same workspace. nil preserves source compatibility for direct Agent
|
||
// construction; boot always supplies it for Delivery sessions.
|
||
WorkspaceLease *workspacelease.Owner
|
||
|
||
// ProjectChecks are host-observable structured checks extracted during boot.
|
||
ProjectChecks []instruction.VerifyCheck
|
||
|
||
// DeliveryProfile enforces acceptance criteria before mutations and requires
|
||
// post-change review, verification, and evidence-backed sign-off before a
|
||
// final answer. It changes host control flow, not tool schemas.
|
||
DeliveryProfile bool
|
||
|
||
// Ablation switches subsystems off for a benchmark arm. The zero value runs
|
||
// everything, so ordinary callers leave it unset.
|
||
Ablation ablation.Set
|
||
|
||
// ClassifierTaskText, when non-empty, is the pristine task text delivery
|
||
// intent classification should judge instead of the raw Run input. Sub-agent
|
||
// spawners set it before prepending host framing (subagent/workspace context,
|
||
// review contracts) so framing verbs cannot arm expectations and user input
|
||
// dressed up as framing cannot disarm them.
|
||
ClassifierTaskText string
|
||
|
||
// CapabilityLedger is the optional turn-scoped capability route ledger for
|
||
// Delivery require/prefer gates. Nil disables capability gates.
|
||
CapabilityLedger *capability.Ledger
|
||
// CapabilityAudit is the optional non-persisted metrics sink for routing.
|
||
CapabilityAudit *capability.Audit
|
||
|
||
// RequireReviewReportKind, when non-empty, makes RunSubAgentWithSession fail
|
||
// unless the subagent recorded a successful review_report of this kind —
|
||
// review/security subagents must return typed, host-verifiable reports.
|
||
RequireReviewReportKind evidence.ReviewKind
|
||
|
||
// ReasoningLanguage controls visible reasoning language preference as transient
|
||
// user-turn context. Empty/auto injects nothing.
|
||
ReasoningLanguage string
|
||
|
||
// ResponseLanguage controls final-answer language preference as transient
|
||
// user-turn context. Empty/auto keeps the stable same-as-user policy.
|
||
ResponseLanguage string
|
||
|
||
// PlanModeReadOnlyCommands is retained for old config/controller data. Main
|
||
// Plan execution classifies bash through Permissions instead.
|
||
PlanModeReadOnlyCommands []string
|
||
|
||
// RecoveryGate is the optional Auto Guard boundary. It checks deterministic
|
||
// high-risk mutations and failure recovery before permission approval and
|
||
// write-lock acquisition.
|
||
RecoveryGate RecoveryGate
|
||
// RecoveryAgentID labels this agent on recovery cards (empty = root).
|
||
RecoveryAgentID string
|
||
// RecoveryTaskID isolates recovery state for this agent (empty = root task).
|
||
RecoveryTaskID string
|
||
|
||
// SubagentDepth is the current nesting depth for this agent. Root sessions are
|
||
// depth 0; child subagents are depth 1. MaxSubagentDepth caps delegation.
|
||
SubagentDepth int
|
||
MaxSubagentDepth int
|
||
|
||
// Extensions is the frozen extension dispatcher for this agent's controller
|
||
// generation (Extension Protocol v1). Nil means no v1 runtime packages are
|
||
// installed; the run loop then passes every intercept point through
|
||
// byte-identically. Boot installs it with SetExtensions once sidecars are
|
||
// live (they start after the agent is constructed).
|
||
Extensions *dispatch.Dispatcher
|
||
|
||
// MutationObserver is the host-side file mutation observer shared with
|
||
// (or cloned for) sub-agents. nil disables v2 capture. Does not affect
|
||
// provider-visible tool schemas or prompts.
|
||
MutationObserver *checkpoint.MutationObserver
|
||
}
|
||
|
||
// New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
|
||
// until the model gives a final answer, the context is cancelled, or the
|
||
// provider errors (compaction keeps the context bounded). A nil sink is replaced
|
||
// with event.Discard so the agent can always emit unconditionally.
|
||
func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, sink event.Sink) *Agent {
|
||
if opts.SoftCompactRatio <= 0 {
|
||
opts.SoftCompactRatio = defaultSoftCompactRatio
|
||
}
|
||
if opts.ToolResultSnipRatio <= 0 {
|
||
opts.ToolResultSnipRatio = defaultToolResultSnipRatio
|
||
}
|
||
if opts.CompactRatio <= 0 {
|
||
opts.CompactRatio = defaultCompactRatio
|
||
}
|
||
if opts.ToolResultSnipRatio >= opts.CompactRatio {
|
||
opts.ToolResultSnipRatio = opts.CompactRatio
|
||
}
|
||
if opts.CompactForceRatio <= 0 {
|
||
opts.CompactForceRatio = defaultCompactForceRatio
|
||
}
|
||
if opts.RecentKeep <= 0 {
|
||
opts.RecentKeep = minRecentKeep
|
||
}
|
||
if nilutil.IsNil(sink) {
|
||
sink = event.Discard
|
||
}
|
||
gate := opts.Gate
|
||
if nilutil.IsNil(gate) {
|
||
gate = nil
|
||
}
|
||
planModeReadOnlyTrust := opts.PlanModeReadOnlyTrustGate
|
||
if nilutil.IsNil(planModeReadOnlyTrust) {
|
||
planModeReadOnlyTrust = nil
|
||
}
|
||
sandboxEscapeApprover := opts.SandboxEscapeApprover
|
||
if nilutil.IsNil(sandboxEscapeApprover) {
|
||
sandboxEscapeApprover = nil
|
||
}
|
||
configWriteApprover := opts.ConfigWriteApprover
|
||
if nilutil.IsNil(configWriteApprover) {
|
||
configWriteApprover = nil
|
||
}
|
||
hooks := opts.Hooks
|
||
if nilutil.IsNil(hooks) {
|
||
hooks = nil
|
||
}
|
||
maxStepsKey := opts.MaxStepsKey
|
||
if strings.TrimSpace(maxStepsKey) == "" {
|
||
maxStepsKey = "max_steps"
|
||
}
|
||
maxSubagentDepth := opts.MaxSubagentDepth
|
||
if maxSubagentDepth == 0 {
|
||
maxSubagentDepth = DefaultMaxSubagentDepth
|
||
} else {
|
||
maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
|
||
}
|
||
subagentDepth := max(opts.SubagentDepth, 0)
|
||
reasoningByteLimit := opts.ReasoningByteLimit
|
||
if reasoningByteLimit == 0 {
|
||
reasoningByteLimit = defaultReasoningByteLimit
|
||
}
|
||
a := &Agent{
|
||
prov: prov,
|
||
tools: tools,
|
||
session: session,
|
||
maxSteps: opts.MaxSteps,
|
||
maxStepsKey: maxStepsKey,
|
||
reasoningByteLimit: reasoningByteLimit,
|
||
maxOutputTokens: opts.MaxOutputTokens,
|
||
temperature: opts.Temperature,
|
||
pricing: opts.Pricing,
|
||
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
|
||
modelRef: strings.TrimSpace(opts.ModelRef),
|
||
sink: sink,
|
||
gate: gate,
|
||
extensions: opts.Extensions,
|
||
recoveryGate: opts.RecoveryGate,
|
||
recoveryAgentID: strings.TrimSpace(opts.RecoveryAgentID),
|
||
recoveryTaskID: strings.TrimSpace(opts.RecoveryTaskID),
|
||
readOnlyExecution: opts.ReadOnlyExecution,
|
||
plannerMCPExecution: opts.PlannerMCPExecution,
|
||
planModeReadOnlyTrust: planModeReadOnlyTrust,
|
||
sandboxEscapeApprover: sandboxEscapeApprover,
|
||
configWriteApprover: configWriteApprover,
|
||
hooks: hooks,
|
||
jobs: opts.Jobs,
|
||
writeScheduler: opts.WriteScheduler,
|
||
writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
|
||
workspaceLease: opts.WorkspaceLease,
|
||
missingReasoningWarnState: missingReasoningWarnStateFor(opts.MissingReasoningWarnStateDir),
|
||
evidence: evidence.NewLedger(),
|
||
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
||
deliveryProfile: opts.DeliveryProfile,
|
||
ablation: opts.Ablation,
|
||
classifierTaskText: opts.ClassifierTaskText,
|
||
capabilityLedger: opts.CapabilityLedger,
|
||
capabilityAudit: opts.CapabilityAudit,
|
||
contextWindow: opts.ContextWindow,
|
||
softCompactRatio: opts.SoftCompactRatio,
|
||
toolResultSnipRatio: opts.ToolResultSnipRatio,
|
||
compactRatio: opts.CompactRatio,
|
||
compactForceRatio: opts.CompactForceRatio,
|
||
recentKeep: opts.RecentKeep,
|
||
archiveDir: opts.ArchiveDir,
|
||
keepPolicy: opts.KeepPolicy,
|
||
subagentDepth: subagentDepth,
|
||
maxSubagentDepth: maxSubagentDepth,
|
||
mutationObserver: opts.MutationObserver,
|
||
}
|
||
a.SetResponseLanguage(opts.ResponseLanguage)
|
||
a.SetReasoningLanguage(opts.ReasoningLanguage)
|
||
return a
|
||
}
|
||
|
||
func usageSourceOrDefault(source, fallback string) string {
|
||
source = strings.TrimSpace(source)
|
||
if source != "" {
|
||
return source
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// missingReasoningWarnStateFor returns nil when no state dir is configured, so
|
||
// direct Agent construction keeps the historical once-per-session notice scope.
|
||
func missingReasoningWarnStateFor(dir string) *missingReasoningWarnState {
|
||
if strings.TrimSpace(dir) == "" {
|
||
return nil
|
||
}
|
||
return newMissingReasoningWarnState(dir)
|
||
}
|
||
|
||
// reserveParentWrite holds write claims for the duration of a parent-agent
|
||
// write tool call. Returns a no-op release when reservation is not needed
|
||
// (subagent, read-only, no scheduler, or non-write tool).
|
||
func (a *Agent) reserveParentWrite(runTool tool.Tool, args json.RawMessage, readOnly bool) (release func(), err error) {
|
||
noop := func() {}
|
||
if a == nil || a.writeScheduler == nil || a.subagentDepth > 0 || readOnly || runTool == nil {
|
||
return noop, nil
|
||
}
|
||
name := runTool.Name()
|
||
if !parentWriteGuardTarget(name) {
|
||
return noop, nil
|
||
}
|
||
claim, err := parentWriteReservation(a.writeWorkspaceRoot, name, args)
|
||
if err != nil {
|
||
return noop, err
|
||
}
|
||
return a.writeScheduler.ReserveParentWrite(claim)
|
||
}
|
||
|
||
// Run appends the user input and drives the tool loop until the model returns a
|
||
// final answer (no tool calls), the context is cancelled, or the provider errors.
|
||
// With maxSteps <= 0 the loop is unbounded — the natural termination is the model
|
||
// finishing, and the real safety bounds are user cancellation and compaction, not
|
||
// a round count. A positive maxSteps imposes an optional hard guard, surfaced as
|
||
// a resumable notice when hit.
|
||
// Run is the agent lifecycle entry point: lifecycle setup, turn initialization,
|
||
// tool-round loop, and deferred cleanup. Turn policy lives in beginRunTurn /
|
||
// runToolLoop / handleFinalResponse / handleToolRound so the state machine stays
|
||
// readable without changing provider-visible behavior or lock ownership.
|
||
func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||
runMaxSteps := a.maxSteps
|
||
runMaxStepsKey := a.maxStepsKey
|
||
runLimitHostOwned := false
|
||
if limit, ok := runStepLimitFromContext(ctx); ok {
|
||
runMaxSteps = limit.steps
|
||
runLimitHostOwned = true
|
||
if limit.key != "" {
|
||
runMaxStepsKey = limit.key
|
||
}
|
||
}
|
||
a.recoveryRunSeq.Add(1)
|
||
if a.deliveryProfile && a.workspaceLease != nil {
|
||
a.workspaceLease.BeginRun()
|
||
defer a.workspaceLease.EndRun()
|
||
}
|
||
turnStartedAt := time.Now()
|
||
workDurationMs := func() int64 {
|
||
if elapsed := time.Since(turnStartedAt).Milliseconds(); elapsed > 0 {
|
||
return elapsed
|
||
}
|
||
return 1
|
||
}
|
||
defer a.flushSteerQueue()
|
||
a.steerMu.Lock()
|
||
a.steerConsumed = false
|
||
a.steerRunActive = true
|
||
a.steerMu.Unlock()
|
||
|
||
// Commit background-job evidence leases only after this turn delivers.
|
||
// wait/bash_output merge a finished background writer's receipts into the
|
||
// ledger provisionally; if the turn reaches a final answer (runErr == nil)
|
||
// the delivery gates have verified and reviewed those mutations, so the
|
||
// job's evidence can be permanently drained. A failed or cancelled turn
|
||
// leaves the lease uncommitted so the next turn re-collects it.
|
||
defer func() {
|
||
if runErr != nil || a.evidence == nil || a.jobs == nil {
|
||
return
|
||
}
|
||
for _, lease := range a.evidence.BackgroundLeases() {
|
||
a.jobs.CommitEvidenceForSession(lease.Session, lease.JobID)
|
||
}
|
||
}()
|
||
if _, scoped := DeliveryExecutionScopeFromContext(ctx); scoped {
|
||
defer func() { a.updateDeliveryCheckpoint(runErr) }()
|
||
}
|
||
defer a.activeTurnCreatedAt.Store(0)
|
||
|
||
// agent.before_start: an extension may abort the run before the user turn
|
||
// is appended. The redacted reason surfaces like a normal run error.
|
||
if err := a.interceptAgentStart(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
_, state := a.beginRunTurn(ctx, input)
|
||
state.runMaxSteps = runMaxSteps
|
||
state.runMaxStepsKey = runMaxStepsKey
|
||
state.runLimitHostOwned = runLimitHostOwned
|
||
state.workDurationMs = workDurationMs
|
||
return a.runToolLoop(ctx, state)
|
||
}
|
||
|
||
// observeMissingToolCallReasoning classifies a thinking-mode tool-call turn and
|
||
// claims the single silent retry allowed for its active compatibility incident.
|
||
// DeepSeek requires provider-issued thinking content to be replayed, so a
|
||
// missing value is retried once before tools execute. Persistent broken rounds
|
||
// use the existing exact-configuration cooldown; a healthy round resolves the
|
||
// incident after three consecutive healthy turns and re-arms a future isolated
|
||
// regression (#6259, #7059).
|
||
func (a *Agent) observeMissingToolCallReasoning(calls []provider.ToolCall, reasoning string) (missing, shouldRetry bool) {
|
||
if len(calls) == 0 || !provider.WarnOnMissingToolCallReasoning(a.prov) {
|
||
return false, false
|
||
}
|
||
fingerprint := provider.MissingToolCallReasoningWarningFingerprint(a.prov)
|
||
observedAt := time.Now()
|
||
if strings.TrimSpace(reasoning) != "" {
|
||
if a.missingReasoningWarnState == nil {
|
||
if a.warnedMissingToolCallReasoning {
|
||
a.missingReasoningHealthyStreak++
|
||
if a.missingReasoningHealthyStreak >= missingReasoningHealthyResolveStreak {
|
||
a.warnedMissingToolCallReasoning = false
|
||
a.missingReasoningHealthyStreak = 0
|
||
}
|
||
}
|
||
return false, false
|
||
}
|
||
shouldResolve := !a.missingReasoningWarnStateChecked || a.warnedMissingToolCallReasoning
|
||
if shouldResolve {
|
||
result := missingReasoningResolveResult{Recorded: true, Resolved: true}
|
||
if pending := a.missingReasoningWarnPendingResolveAt; !pending.IsZero() {
|
||
result = a.missingReasoningWarnState.resolveAt(fingerprint, pending)
|
||
if result.Recorded {
|
||
a.missingReasoningWarnPendingResolveAt = time.Time{}
|
||
}
|
||
}
|
||
if result.Recorded {
|
||
result = a.missingReasoningWarnState.resolveAt(fingerprint, observedAt)
|
||
}
|
||
if !result.Recorded {
|
||
if observedAt.After(a.missingReasoningWarnPendingResolveAt) {
|
||
a.missingReasoningWarnPendingResolveAt = observedAt
|
||
}
|
||
a.warnedMissingToolCallReasoning = true
|
||
a.missingReasoningWarnStateChecked = false
|
||
} else if result.Resolved {
|
||
a.warnedMissingToolCallReasoning = false
|
||
a.missingReasoningWarnStateChecked = true
|
||
} else {
|
||
a.warnedMissingToolCallReasoning = true
|
||
a.missingReasoningWarnStateChecked = false
|
||
}
|
||
}
|
||
return false, false
|
||
}
|
||
a.missingReasoningHealthyStreak = 0
|
||
if s := a.missingReasoningWarnState; s != nil {
|
||
stateReady := true
|
||
alreadyActive := a.warnedMissingToolCallReasoning
|
||
if pending := a.missingReasoningWarnPendingResolveAt; !pending.IsZero() {
|
||
result := s.resolveAt(fingerprint, pending)
|
||
stateReady = result.Recorded
|
||
if result.Recorded {
|
||
a.missingReasoningWarnPendingResolveAt = time.Time{}
|
||
if result.Resolved {
|
||
alreadyActive = false
|
||
a.warnedMissingToolCallReasoning = false
|
||
}
|
||
}
|
||
}
|
||
claimed := stateReady && s.claimAt(fingerprint, observedAt)
|
||
if !claimed || alreadyActive {
|
||
// This exact configuration already attempted recovery for the active
|
||
// incident, so keep the empty-key fallback without doubling requests.
|
||
a.warnedMissingToolCallReasoning = true
|
||
a.missingReasoningWarnStateChecked = true
|
||
return true, false
|
||
}
|
||
if !stateReady {
|
||
a.missingReasoningWarnStateChecked = false
|
||
}
|
||
} else if a.warnedMissingToolCallReasoning {
|
||
return true, false
|
||
}
|
||
a.warnedMissingToolCallReasoning = true
|
||
if a.missingReasoningWarnPendingResolveAt.IsZero() {
|
||
a.missingReasoningWarnStateChecked = true
|
||
}
|
||
return true, true
|
||
}
|
||
|
||
// maxStepsPause is the deliberate stop when a positive tool-call budget runs
|
||
// out: the session already holds the completed work and the user is asked to
|
||
// continue. It is a control-flow signal, not a provider failure. Coordinator
|
||
// treats planner research budgets specially: ordinary plan-and-execute work
|
||
// falls back to the executor, while explicit execution boundaries fail closed.
|
||
type maxStepsPause struct {
|
||
steps int
|
||
key string
|
||
}
|
||
|
||
func (e *maxStepsPause) Error() string {
|
||
return fmt.Sprintf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", e.steps, e.key, e.key)
|
||
}
|
||
|
||
type todoStallPause struct {
|
||
rounds int
|
||
}
|
||
|
||
func (e *todoStallPause) Error() string {
|
||
return fmt.Sprintf("paused after %d tool-call rounds without advancing the current todo — the work so far is saved; inspect the blocker or send another message to continue", e.rounds)
|
||
}
|
||
|
||
func isToolLoopPause(err error) bool {
|
||
var maxPause *maxStepsPause
|
||
var stallPause *todoStallPause
|
||
return errors.As(err, &maxPause) || errors.As(err, &stallPause)
|
||
}
|
||
|
||
// ReadinessResult is the host-consumable outcome of the Delivery final-answer
|
||
// readiness check. The Controller reads it after each goal turn; plain turns
|
||
// receive the same outcome as a FinalReadinessError.
|
||
type ReadinessResult struct {
|
||
// Ready is true when no missing requirement remains.
|
||
Ready bool
|
||
// Missing lists stable category ids of the missing requirements
|
||
// (project_check, todo, criteria, verification, review, signoff, action,
|
||
// mutation, capability). Empty when Ready.
|
||
Missing []string
|
||
// Reason is the user-facing summary of what is still missing.
|
||
Reason string
|
||
// ProgressKey is the host-verifiable progress signature of the current
|
||
// evidence state. Identical ProgressKey across consecutive goal turns
|
||
// means no host-observable progress was made.
|
||
ProgressKey string
|
||
}
|
||
|
||
// ReadinessResult returns the current final-readiness outcome for the host.
|
||
func (a *Agent) ReadinessResult() ReadinessResult {
|
||
check := a.finalReadinessCheckFor()
|
||
if check.reason == "" {
|
||
return ReadinessResult{Ready: true, ProgressKey: check.progressSignature()}
|
||
}
|
||
return ReadinessResult{
|
||
Ready: false,
|
||
Missing: check.missingIDs(),
|
||
Reason: check.reason,
|
||
ProgressKey: check.progressSignature(),
|
||
}
|
||
}
|
||
|
||
// HostProgressSignature returns a compact signature of host-observable progress
|
||
// across the current delivery scope: successful writes, commands, todo writes,
|
||
// signoffs, and reviews. Identical signatures across consecutive goal turns
|
||
// mean no host-verifiable progress was made — reads, reworded answers, and
|
||
// repeated continue reasons never reset the stall counter.
|
||
func (a *Agent) HostProgressSignature() string {
|
||
if a == nil || a.evidence == nil {
|
||
return ""
|
||
}
|
||
s := a.evidence.ReceiptProgressSummary()
|
||
return fmt.Sprintf("w=%d;c=%d;t=%d;s=%d;r=%d", s.Writes, s.Commands, s.Todos, s.Signoffs, s.Reviews)
|
||
}
|
||
|
||
type finalReadinessCheck struct {
|
||
applies bool
|
||
reason string
|
||
missingProjectChecks int
|
||
incompleteTodos int
|
||
missingAcceptanceCriteria int
|
||
missingVerification int
|
||
missingReview int
|
||
missingSignoff int
|
||
missingActionEvidence int
|
||
missingMutation int
|
||
missingCapabilities int
|
||
}
|
||
|
||
func (c finalReadinessCheck) progressSignature() string {
|
||
return fmt.Sprintf("%d/%d/%d/%d/%d/%d/%d/%d/%d/%d\x00%s",
|
||
c.missingProjectChecks,
|
||
c.incompleteTodos,
|
||
c.missingAcceptanceCriteria,
|
||
c.missingVerification,
|
||
c.missingReview,
|
||
c.missingSignoff,
|
||
c.missingActionEvidence,
|
||
c.missingMutation,
|
||
c.missingCapabilities,
|
||
boolInt(c.applies),
|
||
c.reason,
|
||
)
|
||
}
|
||
|
||
func (c finalReadinessCheck) missingIDs() []string {
|
||
missing := make([]string, 0, 9)
|
||
add := func(id string, count int) {
|
||
if count > 0 {
|
||
missing = append(missing, id)
|
||
}
|
||
}
|
||
add("project_check", c.missingProjectChecks)
|
||
add("todo", c.incompleteTodos)
|
||
add("criteria", c.missingAcceptanceCriteria)
|
||
add("verification", c.missingVerification)
|
||
add("review", c.missingReview)
|
||
add("signoff", c.missingSignoff)
|
||
add("action", c.missingActionEvidence)
|
||
add("mutation", c.missingMutation)
|
||
add("capability", c.missingCapabilities)
|
||
return missing
|
||
}
|
||
|
||
func boolInt(v bool) int {
|
||
if v {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (c finalReadinessCheck) audit(result evidence.ReadinessAuditResult, recovered bool) evidence.ReadinessAudit {
|
||
return evidence.ReadinessAudit{
|
||
Result: result,
|
||
Recovered: recovered,
|
||
MissingProjectChecks: c.missingProjectChecks,
|
||
IncompleteTodos: c.incompleteTodos,
|
||
CommandMismatchMissing: c.missingProjectChecks,
|
||
MissingAcceptanceCriteria: c.missingAcceptanceCriteria,
|
||
MissingVerification: c.missingVerification,
|
||
MissingReview: c.missingReview,
|
||
MissingSignoff: c.missingSignoff,
|
||
MissingActionEvidence: c.missingActionEvidence,
|
||
MissingMutation: c.missingMutation,
|
||
MissingCapabilities: c.missingCapabilities,
|
||
}
|
||
}
|
||
|
||
func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||
if a.evidence == nil || a.ablation.Off(ablation.Evidence) {
|
||
return finalReadinessCheck{}
|
||
}
|
||
var missing []string
|
||
out := finalReadinessCheck{}
|
||
// Planning returns a proposal to the controller, which owns the approval gate
|
||
// and starts a fresh execution turn after Plan is disabled. Delivery completion
|
||
// requirements, including required capabilities, wait for that execution turn:
|
||
// forcing them here could make a writer requirement contradict the plan-first
|
||
// workflow. This is a workflow boundary only; model-initiated tool calls above
|
||
// still use the normal Permissions/Sandbox path.
|
||
if a.planMode.Load() {
|
||
return out
|
||
}
|
||
{
|
||
incomplete, hasTodos := a.evidence.IncompleteLatestTodos()
|
||
if !hasTodos && a.evidence.HasAnySuccessfulReceipt() {
|
||
incomplete, hasTodos = a.incompleteCanonicalTodos()
|
||
}
|
||
if hasTodos && len(incomplete) > 0 && a.evidence.HasSuccessfulTodoProgressReceipt() {
|
||
out.applies = true
|
||
out.incompleteTodos = len(incomplete)
|
||
missing = append(missing, finalReadinessIncompleteTodos(incomplete))
|
||
}
|
||
}
|
||
writer, hasWriter := a.evidence.LatestSuccessfulWriterIndex()
|
||
deliveryMutation := false
|
||
deliveryVerificationOnly := false
|
||
checkpoint := a.deliveryCheckpoint
|
||
checkpointApplies := a.deliveryScopeActive && checkpoint.ScopeID == a.deliveryScopeID
|
||
if a.deliveryProfile {
|
||
if mutation, ok := a.evidence.LatestSuccessfulMutationIndex(); ok {
|
||
writer, hasWriter = mutation, true
|
||
deliveryMutation = true
|
||
} else if checkpointApplies && checkpoint.PendingMutation {
|
||
// The mutation happened before a controller rebuild/restart. Treat it as
|
||
// the baseline so this run can satisfy verification/review/sign-off
|
||
// without manufacturing another write.
|
||
writer, hasWriter = -1, true
|
||
deliveryMutation = true
|
||
} else if checkpointApplies && checkpoint.MutationObserved {
|
||
deliveryMutation = true
|
||
}
|
||
workObserved := a.evidence.HasSuccessfulWorkReceipt() || (checkpointApplies && checkpoint.WorkObserved)
|
||
if a.deliveryTaskExpected && !a.deliveryPersistentExpected && !workObserved {
|
||
out.missingActionEvidence++
|
||
missing = append(missing, "perform host-observable work for this technical task before answering")
|
||
}
|
||
if a.deliveryPersistentExpected && !a.evidence.HasSuccessfulToolReceipt("remember") {
|
||
out.missingMutation++
|
||
missing = append(missing, "save the requested durable memory with the remember tool before answering")
|
||
}
|
||
if a.deliveryMutationExpected && !deliveryMutation {
|
||
out.missingMutation++
|
||
missing = append(missing, "the request requires a state change, but no successful mutation was observed")
|
||
}
|
||
if !hasWriter && a.evidence.HasSuccessfulVerificationCommand() {
|
||
writer, hasWriter = -1, true
|
||
deliveryVerificationOnly = true
|
||
}
|
||
// Required/preferred capability gates apply before the no-writer fast
|
||
// path below: a user-required Skill/MCP must not be skippable by
|
||
// answering from ordinary reads alone.
|
||
if msg := a.capabilityGateFailure(); msg != "" {
|
||
out.applies = true
|
||
out.missingCapabilities++
|
||
missing = append(missing, msg)
|
||
}
|
||
if a.deliveryPersistentExpected && !a.deliveryMutationExpected && !a.evidence.HasSuccessfulMutationOtherThan("remember") {
|
||
// A durable-memory-only request has its own concrete receipt contract.
|
||
// It must not inherit code-delivery todo/test/diff/review ceremonies;
|
||
// any unrelated mutation falls through to the full contract below.
|
||
out.applies = true
|
||
if len(missing) > 0 {
|
||
out.reason = strings.Join(missing, "; ")
|
||
}
|
||
return out
|
||
}
|
||
}
|
||
if !hasWriter {
|
||
if len(missing) > 0 {
|
||
if a.loopGuardAllowsFinal() {
|
||
return out
|
||
}
|
||
out.reason = strings.Join(missing, "; ")
|
||
}
|
||
return out
|
||
}
|
||
hasProjectChecks := len(a.projectChecks) > 0
|
||
hasTodoReceipt := a.evidence.HasSuccessfulTodoWrite()
|
||
if !a.deliveryProfile && !hasProjectChecks && !hasTodoReceipt && len(missing) == 0 {
|
||
return finalReadinessCheck{}
|
||
}
|
||
out.applies = true
|
||
if a.deliveryProfile {
|
||
criteriaEstablished := a.deliveryCriteriaEstablished || (checkpointApplies && checkpoint.CriteriaEstablished)
|
||
if !criteriaEstablished {
|
||
out.missingAcceptanceCriteria++
|
||
missing = append(missing, "establish concrete acceptance criteria with todo_write before changing state")
|
||
}
|
||
hasCompleteStep := a.evidence.HasSuccessfulCompleteStepAfter(writer)
|
||
if !hasCompleteStep {
|
||
out.missingSignoff++
|
||
missing = append(missing, "call complete_step after the latest mutation")
|
||
}
|
||
if !a.evidence.HasSuccessfulDeliverySignoffAfter(writer) {
|
||
out.missingVerification++
|
||
missing = append(missing, "run relevant verification after the latest mutation and cite that successful command in complete_step")
|
||
}
|
||
if deliveryMutation && !a.evidence.HasSuccessfulReviewAfter(writer) {
|
||
out.missingReview++
|
||
missing = append(missing, "inspect the changed result after the latest mutation (read the touched file or run git diff/status)")
|
||
}
|
||
if msg := a.deliveryReviewGateFailure(); msg != "" {
|
||
out.missingReview++
|
||
missing = append(missing, msg)
|
||
}
|
||
// The capability gate already ran before the no-writer fast path above.
|
||
}
|
||
for _, check := range a.projectChecks {
|
||
if deliveryVerificationOnly {
|
||
break
|
||
}
|
||
command := strings.TrimSpace(check.Command)
|
||
if command == "" {
|
||
continue
|
||
}
|
||
if !a.evidence.HasSuccessfulCommandAfter(command, writer) {
|
||
out.missingProjectChecks++
|
||
missing = append(missing, fmt.Sprintf("run %q from %s after the latest write", command, finalReadinessCheckSource(check)))
|
||
}
|
||
}
|
||
|
||
if len(missing) == 0 {
|
||
return out
|
||
}
|
||
if a.loopGuardAllowsFinal() {
|
||
return out
|
||
}
|
||
out.reason = strings.Join(missing, "; ")
|
||
return out
|
||
}
|
||
|
||
// DeliveryCheckpoint returns the compact Goal-scoped delivery state. It is safe
|
||
// to persist next to the Goal sidecar because it contains no raw arguments.
|
||
func (a *Agent) DeliveryCheckpoint() evidence.DeliveryCheckpoint {
|
||
return a.deliveryCheckpoint
|
||
}
|
||
|
||
// RestoreDeliveryCheckpoint seeds a rebuilt controller before its next Goal
|
||
// run. A mismatched/empty scope is ignored conservatively.
|
||
func (a *Agent) RestoreDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint) {
|
||
checkpoint.ScopeID = strings.TrimSpace(checkpoint.ScopeID)
|
||
if checkpoint.ScopeID == "" {
|
||
return
|
||
}
|
||
a.deliveryCheckpoint = checkpoint
|
||
a.deliveryScopeID = checkpoint.ScopeID
|
||
}
|
||
|
||
// PrepareDeliveryRecovery preserves the exhausted turn's evidence for exactly
|
||
// one explicit continuation. It returns false when there is no matching
|
||
// readiness failure, so normal follow-up turns cannot inherit stale mutations.
|
||
func (a *Agent) PrepareDeliveryRecovery() bool {
|
||
if !a.deliveryProfile || !a.deliveryRecoveryPending {
|
||
return false
|
||
}
|
||
a.preserveEvidenceOnce = true
|
||
a.deliveryRecoveryPending = false
|
||
return true
|
||
}
|
||
|
||
func (a *Agent) updateDeliveryCheckpoint(runErr error) {
|
||
if !a.deliveryScopeActive || a.deliveryScopeID == "" || a.evidence == nil {
|
||
return
|
||
}
|
||
cp := a.deliveryCheckpoint
|
||
if cp.ScopeID != a.deliveryScopeID {
|
||
cp = evidence.DeliveryCheckpoint{ScopeID: a.deliveryScopeID}
|
||
}
|
||
cp.CriteriaEstablished = cp.CriteriaEstablished || a.deliveryCriteriaEstablished || a.evidence.HasSuccessfulTodoWrite()
|
||
cp.WorkObserved = cp.WorkObserved || a.evidence.HasSuccessfulWorkReceipt()
|
||
persistentOnlyReady := a.deliveryPersistentExpected && !a.deliveryMutationExpected &&
|
||
a.evidence.HasSuccessfulToolReceipt("remember") && !a.evidence.HasSuccessfulMutationOtherThan("remember")
|
||
if _, ok := a.evidence.LatestSuccessfulMutationIndex(); ok && !persistentOnlyReady {
|
||
cp.MutationObserved = true
|
||
cp.PendingMutation = true
|
||
}
|
||
if persistentOnlyReady {
|
||
cp.MutationObserved = true
|
||
}
|
||
if runErr == nil && cp.PendingMutation && a.deliveryMutationCheckpointReady() {
|
||
cp.PendingMutation = false
|
||
}
|
||
a.deliveryCheckpoint = cp
|
||
}
|
||
|
||
func (a *Agent) deliveryMutationCheckpointReady() bool {
|
||
if a.evidence == nil || !a.deliveryCriteriaEstablished {
|
||
return false
|
||
}
|
||
mutation, ok := a.evidence.LatestSuccessfulMutationIndex()
|
||
if !ok {
|
||
mutation = -1
|
||
}
|
||
return a.evidence.HasSuccessfulCompleteStepAfter(mutation) &&
|
||
a.evidence.HasSuccessfulDeliverySignoffAfter(mutation) &&
|
||
a.evidence.HasSuccessfulReviewAfter(mutation) &&
|
||
a.deliveryReviewGateFailure() == ""
|
||
}
|
||
|
||
// armLoopGuardPass records that a loop guard fired this user turn.
|
||
// receiptMark is the evidence-ledger receipt count from just before the
|
||
// guarded batch ran, so a successful write or command receipt recorded after
|
||
// it counts as real progress and revokes the pass (see loopGuardAllowsFinal).
|
||
func (a *Agent) armLoopGuardPass(receiptMark int) {
|
||
a.loopGuardArmed = true
|
||
a.loopGuardReceiptMark = receiptMark
|
||
}
|
||
|
||
// loopGuardAllowsFinal reports whether final readiness should stand down: a
|
||
// loop guard fired this user turn and no host-observable progress — a
|
||
// successful write or command receipt — has landed since. In that state the
|
||
// missing receipts are exactly what the blocker prevents, so demanding them
|
||
// would restart the retry loop the guard just broke; the model must be free to
|
||
// report the blocker instead. The bookkeeping the guard recommends (ask,
|
||
// todo_write, complete_step) produces neither write nor command receipts, so
|
||
// it keeps the pass; real progress revokes it because receipts are obtainable
|
||
// again and readiness should resume enforcing them.
|
||
func (a *Agent) loopGuardAllowsFinal() bool {
|
||
if a == nil || !a.loopGuardArmed {
|
||
return false
|
||
}
|
||
if a.evidence == nil {
|
||
return true
|
||
}
|
||
return !a.evidence.HasWriteOrCommandSince(a.loopGuardReceiptMark)
|
||
}
|
||
|
||
func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
|
||
parts := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
label := strings.TrimSpace(item.Content)
|
||
if label == "" {
|
||
label = fmt.Sprintf("todo %d", item.Index)
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%s: %s", label, item.Status))
|
||
}
|
||
return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
|
||
}
|
||
|
||
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
|
||
a.todoMu.Lock()
|
||
a.todoState = evidence.NormalizeSerialTodos(todos)
|
||
a.todoMu.Unlock()
|
||
}
|
||
|
||
// SeedTodoState initializes the canonical task list from a host-generated
|
||
// starter list, such as an approved plan. A new host seed replaces stale state
|
||
// from earlier work so complete_step matches the plan the UI just displayed.
|
||
func (a *Agent) SeedTodoState(todos []evidence.TodoItem) {
|
||
if len(todos) == 0 {
|
||
return
|
||
}
|
||
a.setTodoState(todos)
|
||
}
|
||
|
||
// ReplaceTodoState mirrors a host-generated todo list into the canonical state.
|
||
// It is used when the host, rather than the model, owns the full state transition.
|
||
func (a *Agent) ReplaceTodoState(todos []evidence.TodoItem) {
|
||
a.setTodoState(todos)
|
||
a.recordTodoState(a.CanonicalTodoState())
|
||
}
|
||
|
||
// CanonicalTodoState returns a copy of the host-reconstructed task list.
|
||
func (a *Agent) CanonicalTodoState() []evidence.TodoItem {
|
||
a.todoMu.Lock()
|
||
defer a.todoMu.Unlock()
|
||
return append([]evidence.TodoItem(nil), a.todoState...)
|
||
}
|
||
|
||
func (a *Agent) incompleteCanonicalTodos() ([]evidence.TodoStepMatch, bool) {
|
||
a.todoMu.Lock()
|
||
defer a.todoMu.Unlock()
|
||
if len(a.todoState) == 0 {
|
||
return nil, false
|
||
}
|
||
return evidence.IncompleteTodos(a.todoState), true
|
||
}
|
||
|
||
func (a *Agent) hasIncompleteCanonicalCriteria() bool {
|
||
a.todoMu.Lock()
|
||
defer a.todoMu.Unlock()
|
||
return len(a.todoState) > 0 && len(evidence.IncompleteTodos(a.todoState)) > 0
|
||
}
|
||
|
||
func (a *Agent) hasActiveCanonicalTodo() bool {
|
||
a.todoMu.Lock()
|
||
defer a.todoMu.Unlock()
|
||
for _, todo := range a.todoState {
|
||
if canonicalTodoStatus(todo.Status) == "in_progress" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *Agent) canonicalTodoProgress() (int, bool) {
|
||
a.todoMu.Lock()
|
||
defer a.todoMu.Unlock()
|
||
completed := 0
|
||
incomplete := false
|
||
for _, todo := range a.todoState {
|
||
status := canonicalTodoStatus(todo.Status)
|
||
if status == "completed" {
|
||
completed++
|
||
} else {
|
||
incomplete = true
|
||
}
|
||
}
|
||
return completed, incomplete
|
||
}
|
||
|
||
// registryHasWriterTools reports whether any registered tool can mutate state.
|
||
// A strictly read-only registry (read_only_task / read_only_skill subagents)
|
||
// can never satisfy a "state change required" delivery expectation, so that
|
||
// expectation must not be armed for it.
|
||
func registryHasWriterTools(reg *tool.Registry) bool {
|
||
if reg == nil {
|
||
return false
|
||
}
|
||
for _, name := range reg.Names() {
|
||
if t, ok := reg.Get(name); ok && !t.ReadOnly() {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// advanceCanonicalTodo flips the canonical todo matching a signed-off step to
|
||
// completed (promoting the next pending item to in_progress) and emits a
|
||
// synthetic todo_write so the task panel reflects it without the model
|
||
// re-sending the whole list. No-op when nothing matches or it is already done.
|
||
func (a *Agent) advanceCanonicalTodo(step string) {
|
||
a.todoMu.Lock()
|
||
if len(a.todoState) == 0 {
|
||
a.todoMu.Unlock()
|
||
return
|
||
}
|
||
m, ok := evidence.MatchStep(step, a.todoState)
|
||
if !ok || !evidence.AdvanceSerialTodo(a.todoState, m.Index-1) {
|
||
a.todoMu.Unlock()
|
||
return
|
||
}
|
||
snapshot := append([]evidence.TodoItem(nil), a.todoState...)
|
||
a.todoMu.Unlock()
|
||
a.recordTodoState(snapshot)
|
||
a.emitTodoState(snapshot, m.Index)
|
||
}
|
||
|
||
// recordTodoState logs the host-advanced list as a synthetic todo_write receipt
|
||
// so the per-turn final gate (which reads the ledger's latest todo_write) sees
|
||
// the advance — the model no longer has to re-send a todo_write to mark the
|
||
// completion. It bypasses the todo_write tool, so the completion-transition
|
||
// guard never runs on it.
|
||
func (a *Agent) recordTodoState(todos []evidence.TodoItem) {
|
||
if a.evidence == nil {
|
||
return
|
||
}
|
||
args, err := json.Marshal(map[string]any{"todos": todos})
|
||
if err != nil {
|
||
return
|
||
}
|
||
a.evidence.Record(evidence.ReceiptFromToolCall("todo_write", json.RawMessage(args), true, true))
|
||
}
|
||
|
||
func canonicalTodoStatus(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return "pending"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// emitTodoState emits a synthetic todo_write event so the frontend task panel
|
||
// reflects a host-advanced completion without the model re-sending the list.
|
||
// itemIndex is the 1-based position of the completed todo in the panel.
|
||
func (a *Agent) emitTodoState(todos []evidence.TodoItem, itemIndex int) {
|
||
args, err := json.Marshal(map[string]any{"todos": todos})
|
||
if err != nil {
|
||
return
|
||
}
|
||
id := fmt.Sprintf("host-advance-%d-%d", a.hostAdvanceSeq.Add(1), itemIndex)
|
||
t := event.Tool{ID: id, Name: "todo_write", Args: string(args), ReadOnly: true}
|
||
a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
|
||
t.Output = "task list advanced by complete_step"
|
||
a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
|
||
}
|
||
|
||
// RebuildTodoState re-derives canonical task state from the current session
|
||
// transcript. Call after externally truncating the session (e.g. after a
|
||
// user-cancel strip) so Agent.todoState stays consistent with the messages.
|
||
func (a *Agent) RebuildTodoState() {
|
||
a.rebuildTodoState(a.Session().Snapshot())
|
||
}
|
||
|
||
// rebuildTodoState reconstructs the canonical task list from a transcript: the
|
||
// latest successful todo_write is the base, then every complete_step after it
|
||
// advances an item. Deterministic from persisted messages, so it survives a
|
||
// fresh load or a rewind (the truncated history yields the historical state).
|
||
// Empty after compaction drops the todo_write — no worse than no canonical list.
|
||
func (a *Agent) rebuildTodoState(msgs []provider.Message) {
|
||
successful := successfulToolCallIDs(msgs)
|
||
var todos []evidence.TodoItem
|
||
baseIdx := -1
|
||
for i, msg := range msgs {
|
||
for _, tc := range msg.ToolCalls {
|
||
if tc.Name != "todo_write" || !successful[tc.ID] {
|
||
continue
|
||
}
|
||
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
|
||
// A successful empty todo_write is an explicit clear. Preserve it as the
|
||
// latest base so history reloads do not resurrect an older non-empty list.
|
||
todos = evidence.NormalizeSerialTodos(rec.Todos)
|
||
baseIdx = i
|
||
}
|
||
}
|
||
if baseIdx < 0 {
|
||
a.setTodoState(nil)
|
||
return
|
||
}
|
||
for i := baseIdx; i < len(msgs); i++ {
|
||
for _, tc := range msgs[i].ToolCalls {
|
||
if tc.Name != "complete_step" || !successful[tc.ID] {
|
||
continue
|
||
}
|
||
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
|
||
if m, ok := evidence.MatchStep(rec.Step, todos); ok {
|
||
evidence.AdvanceSerialTodo(todos, m.Index-1)
|
||
}
|
||
}
|
||
}
|
||
a.setTodoState(todos)
|
||
}
|
||
|
||
func successfulToolCallIDs(msgs []provider.Message) map[string]bool {
|
||
successful := map[string]bool{}
|
||
for _, msg := range msgs {
|
||
if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
|
||
continue
|
||
}
|
||
if !toolResultFailed(msg.Content) {
|
||
successful[msg.ToolCallID] = true
|
||
}
|
||
}
|
||
return successful
|
||
}
|
||
|
||
func toolResultFailed(content string) bool {
|
||
content = strings.TrimSpace(content)
|
||
return strings.HasPrefix(content, "error:") ||
|
||
strings.HasPrefix(content, "blocked:") ||
|
||
strings.HasPrefix(content, "Error:") ||
|
||
strings.HasPrefix(content, "[error")
|
||
}
|
||
|
||
func finalReadinessCheckSource(check instruction.VerifyCheck) string {
|
||
source := strings.TrimSpace(check.SourcePath)
|
||
if source == "" {
|
||
source = "project memory"
|
||
}
|
||
if check.Line > 0 {
|
||
return fmt.Sprintf("%s:%d", source, check.Line)
|
||
}
|
||
return source
|
||
}
|
||
|
||
func shouldNudgeExecutorHandoff(input, answer string) bool {
|
||
return !executorHandoffAllowsTextOnly(input, answer)
|
||
}
|
||
|
||
func executorHandoffAllowsTextOnly(input, answer string) bool {
|
||
if looksLikeExecutorHandoffDeferral(answer) {
|
||
return false
|
||
}
|
||
task, plan, ok := parseExecutorHandoff(input)
|
||
if !ok {
|
||
return false
|
||
}
|
||
if handoffTaskLooksTextOnly(task) {
|
||
return true
|
||
}
|
||
return handoffPlanLooksTextOnly(plan)
|
||
}
|
||
|
||
func parseExecutorHandoff(input string) (task, plan string, ok bool) {
|
||
input = StripTransientUserBlocks(input)
|
||
marker := "# " + executorHandoffMarker
|
||
i := strings.Index(input, marker)
|
||
if i < 0 {
|
||
return "", "", false
|
||
}
|
||
input = input[i+len(marker):]
|
||
_, input, ok = strings.Cut(input, "\n\nOriginal task:\n")
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
task, input, ok = strings.Cut(input, "\n\nPlanner output:\n")
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
plan, _, ok = strings.Cut(input, "\n\nExecutor instructions:")
|
||
if !ok {
|
||
return "", "", false
|
||
}
|
||
if beforeToolContext, _, found := strings.Cut(plan, "\n\nExecutor tool context:"); found {
|
||
plan = beforeToolContext
|
||
}
|
||
return strings.TrimSpace(task), strings.TrimSpace(plan), true
|
||
}
|
||
|
||
func looksLikeExecutorHandoffDeferral(answer string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(answer))
|
||
if lower == "" {
|
||
return true
|
||
}
|
||
if containsAnySubstring(lower, executorHandoffDeferralPhrases) {
|
||
return true
|
||
}
|
||
switch strings.Trim(lower, " \t\r\n.!?。!?") {
|
||
case "ok", "okay", "sounds good", "done", "好的", "可以", "没问题", "收到":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func handoffTaskLooksTextOnly(task string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(task))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
if containsAnySubstring(lower, executorHandoffWorkRequestTerms) {
|
||
return false
|
||
}
|
||
return containsAnySubstring(lower, executorHandoffTextOnlyTaskTerms)
|
||
}
|
||
|
||
func handoffPlanLooksTextOnly(plan string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(plan))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
if containsAnySubstring(lower, executorHandoffLocalActionTerms) {
|
||
return false
|
||
}
|
||
if containsAnySubstring(lower, executorHandoffTextOnlyPlanTerms) {
|
||
return true
|
||
}
|
||
return strings.Contains(lower, "?")
|
||
}
|
||
|
||
func containsAnySubstring(s string, terms []string) bool {
|
||
for _, term := range terms {
|
||
if strings.Contains(s, term) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
var executorHandoffDeferralPhrases = []string{
|
||
"plan looks", "looks good", "should be easy", "should be straightforward",
|
||
"i can implement", "i'll implement", "i will implement", "i'll get started",
|
||
"let me ", "i will now", "i'll now", "i can do that",
|
||
"计划看起来", "可以实现", "我会", "我将", "接下来我", "马上开始",
|
||
}
|
||
|
||
var executorHandoffWorkRequestTerms = []string{
|
||
"implement", "fix", "refactor", "migrate", "edit", "write", "create", "delete",
|
||
"update", "remove", "add ", "test", "build", "repair", "patch",
|
||
"修改", "修复", "实现", "新增", "重构", "迁移", "补齐", "更新", "删除", "移除",
|
||
}
|
||
|
||
var executorHandoffTextOnlyTaskTerms = []string{
|
||
"now what", "what next", "tl;dr", "tldr", "summarize", "summary", "explain",
|
||
"i installed", "i just installed", "i turned on", "i enabled", "it's on", "it is on",
|
||
"怎么办", "下一步", "然后呢", "总结", "解释", "说明", "装了", "装好了", "安装了", "开了", "开启了", "打开了",
|
||
}
|
||
|
||
var executorHandoffLocalActionTerms = []string{
|
||
"write_file", "read_file", "apply_patch", "bash",
|
||
"workspace", "repo", "repository", "codebase", "file", "path",
|
||
"write ", "edit ", "modify ", "create ", "delete ", "remove ", "update ", "add ", "patch ", "refactor ", "implement ",
|
||
"run ", "command", "test", "build",
|
||
"文件", "路径", "仓库", "代码", "写入", "编辑", "修改", "创建", "删除", "移除", "更新", "新增", "运行", "命令", "测试", "构建",
|
||
}
|
||
|
||
var executorHandoffTextOnlyPlanTerms = []string{
|
||
"tell the user", "ask the user", "guide the user", "explain to the user",
|
||
"summarize", "summary", "tl;dr", "tldr", "answer the user", "respond to the user",
|
||
"provide guidance", "walk the user", "instruct the user", "have the user",
|
||
"user should", "the user should", "user can", "the user can", "manual", "manually",
|
||
"no tools needed", "no tool calls needed", "does not need tools", "needs no tools",
|
||
"listen", "play a song", "compare the difference", "checkbox",
|
||
"告诉用户", "询问用户", "问用户", "让用户", "请用户", "指导用户", "解释", "总结", "回答",
|
||
"手动", "无需工具", "不需要工具", "试听", "听歌", "对比", "勾选",
|
||
}
|
||
|
||
func executorHandoffRetryMessage() string {
|
||
return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
|
||
|
||
The tool schema is still attached to this executor request. Do not invent that MCP servers or tools are unavailable; only report an unavailable tool after a real tool call or host error proves it.
|
||
|
||
Do not answer as the planner and do not ask how to trigger the executor.
|
||
Use your available tools now to carry out the task. If carrying out the planner's instructions requires a user-owned choice or review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so. If a write or command is blocked by permissions or workspace boundaries, state that specific blocker and ask for the needed approval/path.`
|
||
}
|
||
|
||
func hasVisibleFinalAnswer(text string) bool {
|
||
return strings.TrimSpace(text) != ""
|
||
}
|
||
|
||
// reasoningOnlyFinishHonoured reports whether the model finished with a stop
|
||
// signal but placed its answer in the reasoning stream rather than the content
|
||
// block. DeepSeek thinking mode does this occasionally: it streams a long
|
||
// reasoning_content, then returns finish_reason="stop" with an empty content.
|
||
// The model has signalled completion, so the host accepts the turn instead of
|
||
// retrying and forcing another expensive thinking round.
|
||
//
|
||
// The accept is scoped to DeepSeek thinking mode (ToolCallReasoningPolicy):
|
||
// for other providers a reasoning-only turn keeps the empty-final retry
|
||
// safety net — local <think>-tag models often recover a visible answer on
|
||
// the second attempt, and a gateway that mislabels truncation as "stop"
|
||
// must not have a degenerate turn committed as the final answer.
|
||
func reasoningOnlyFinishHonoured(p provider.Provider, u *provider.Usage, reasoning string) bool {
|
||
if !provider.RequiresToolCallReasoning(p) {
|
||
return false
|
||
}
|
||
if u == nil || u.FinishReason != "stop" {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(reasoning) != ""
|
||
}
|
||
|
||
func emptyFinalRetryMessage() string {
|
||
return "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer to the user. Do not send reasoning only."
|
||
}
|
||
|
||
func emptyFinalNotice() string {
|
||
return "No visible answer was produced; asking the assistant to respond again."
|
||
}
|
||
|
||
func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
|
||
finish := "unknown"
|
||
if u != nil && u.FinishReason != "" {
|
||
finish = u.FinishReason
|
||
}
|
||
return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
|
||
}
|
||
|
||
func executorHandoffNoticeText() string {
|
||
return "The assistant answered before taking action; asking it to use the required tools."
|
||
}
|
||
|
||
func toolBudgetNoticeText() string {
|
||
return "Tool round limit reached; asking the assistant to summarize progress."
|
||
}
|
||
|
||
// samplingRequest is a once-prepared, frozen provider request for one model
|
||
// round. All stream retries replay this exact payload — no synthetic recovery
|
||
// messages, no schema reorder, no previous_response_id drift from failed attempts.
|
||
type samplingRequest struct {
|
||
req provider.Request
|
||
}
|
||
|
||
// prepareSamplingRequest runs interceptors and schema fetch once per model
|
||
// round. Callers deep-copy via freezeProviderRequest before each Stream so
|
||
// providers cannot mutate the shared freeze across retries.
|
||
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
|
||
// CreatedAt is durable UI metadata, not model input. Strip it from the
|
||
// transport copy so wall-clock differences never invalidate the provider's
|
||
// prompt-cache prefix (and custom providers cannot accidentally send it).
|
||
requestMessages := append([]provider.Message(nil), provider.ModelMessages(a.session.Messages)...)
|
||
for i := range requestMessages {
|
||
requestMessages[i].CreatedAt = 0
|
||
}
|
||
// context.prepare: extensions may rewrite the message copy feeding THIS
|
||
// request. The session log is never touched — the replacement is
|
||
// ephemeral, so the next request starts from the unmodified history and
|
||
// the prompt-cache prefix stays intact across turns.
|
||
requestMessages, err := a.interceptContextPrepare(ctx, requestMessages)
|
||
if err != nil {
|
||
return samplingRequest{}, err
|
||
}
|
||
req := provider.Request{
|
||
Messages: requestMessages,
|
||
Tools: a.tools.Schemas(),
|
||
MaxTokens: a.maxOutputTokens,
|
||
Temperature: provider.OptionalTemperature(a.temperature),
|
||
ResponseFormat: responseFormatFromRequest(ctx),
|
||
}
|
||
// provider.request: the fully assembled request gets one last ruling
|
||
// (revalidated by the payload registry) before it goes on the wire.
|
||
req, err = a.interceptProviderRequest(ctx, req)
|
||
if err != nil {
|
||
return samplingRequest{}, err
|
||
}
|
||
return samplingRequest{req: freezeProviderRequest(req)}, nil
|
||
}
|
||
|
||
// freezeProviderRequest deep-copies the provider-visible request surface so
|
||
// retries share identical messages, tools order, temperature, and format.
|
||
func freezeProviderRequest(req provider.Request) provider.Request {
|
||
out := req
|
||
if len(req.Messages) > 0 {
|
||
out.Messages = append([]provider.Message(nil), req.Messages...)
|
||
for i := range out.Messages {
|
||
if len(out.Messages[i].ToolCalls) > 0 {
|
||
out.Messages[i].ToolCalls = append([]provider.ToolCall(nil), out.Messages[i].ToolCalls...)
|
||
}
|
||
if len(out.Messages[i].Images) > 0 {
|
||
out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
|
||
}
|
||
if len(out.Messages[i].ResponsesItems) > 0 {
|
||
items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
|
||
for j, item := range out.Messages[i].ResponsesItems {
|
||
items[j] = append(json.RawMessage(nil), item...)
|
||
}
|
||
out.Messages[i].ResponsesItems = items
|
||
}
|
||
}
|
||
}
|
||
if len(req.Tools) > 0 {
|
||
out.Tools = make([]provider.ToolSchema, len(req.Tools))
|
||
for i, schema := range req.Tools {
|
||
out.Tools[i] = schema
|
||
if len(schema.Parameters) > 0 {
|
||
out.Tools[i].Parameters = append(json.RawMessage(nil), schema.Parameters...)
|
||
}
|
||
}
|
||
}
|
||
if req.Temperature != nil {
|
||
t := *req.Temperature
|
||
out.Temperature = &t
|
||
}
|
||
if req.ResponseFormat != nil {
|
||
rf := *req.ResponseFormat
|
||
out.ResponseFormat = &rf
|
||
}
|
||
return out
|
||
}
|
||
|
||
// stream runs one completion, emitting reasoning and text deltas as typed
|
||
// events and collecting complete tool calls. A Message event closes the text
|
||
// stream so a sink can re-render the streamed raw text as styled markdown. The
|
||
// accumulated text and reasoning are also returned so the caller can round-trip
|
||
// reasoning on the next turn.
|
||
//
|
||
// When frozen is non-nil, the request is not rebuilt from session — retries
|
||
// must replay the same provider-visible body.
|
||
func (a *Agent) stream(ctx context.Context, turn int, sink event.Sink) streamedTurn {
|
||
return a.streamWithFrozen(ctx, turn, sink, nil, "")
|
||
}
|
||
|
||
func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink, frozen *samplingRequest, attemptID string) streamedTurn {
|
||
ctx = provider.WithRetryNotify(ctx, func(info provider.RetryInfo) {
|
||
sink.Emit(event.Event{Kind: event.Retrying, RetryAttempt: info.Attempt, RetryMax: info.Max, RetryScope: event.RetryScopeHeaders})
|
||
})
|
||
// Reuse a parent attempt counter when present so stream retries accumulate
|
||
// into one RequestCount; otherwise install a fresh counter for this call.
|
||
ctx = provider.WithRequestAttemptCounter(ctx)
|
||
// A stream can terminate locally before the provider channel closes (for
|
||
// example when the client-side reasoning guard fires). Own a child context
|
||
// here so every return path aborts the HTTP request and releases the provider
|
||
// reader instead of leaving generation and billing running in the background.
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
|
||
var req provider.Request
|
||
var err error
|
||
if frozen != nil {
|
||
req = freezeProviderRequest(frozen.req)
|
||
} else {
|
||
prepared, perr := a.prepareSamplingRequest(ctx)
|
||
if perr != nil {
|
||
return streamedTurn{err: perr}
|
||
}
|
||
req = prepared.req
|
||
}
|
||
// After #7725 Goal token request admission was removed, stream goes
|
||
// directly to the provider. Provider-visible cache controls stay stable
|
||
// across retries and request timing because they are derived from req alone.
|
||
ch, err := a.prov.Stream(ctx, req)
|
||
if err != nil {
|
||
return streamedTurn{usage: provider.UsageWithRequestAttemptCount(ctx, nil), err: err}
|
||
}
|
||
|
||
// A PostLLMCall hook rewrites the whole reasoning block, so when one is wired
|
||
// up we buffer reasoning silently and emit the transformed text once after the
|
||
// stream. With no such hook the reasoning streams live, chunk by chunk, as
|
||
// before — the common case must not lose its live "thinking…" display.
|
||
transformReasoning := a.hooks != nil && a.hooks.HasPostLLMCall()
|
||
|
||
var text, reasoning strings.Builder
|
||
var signature string // provider-issued proof for the reasoning (Anthropic thinking)
|
||
var reasoningID, reasoningStatus string // Responses reasoning item id/status (meta chunk)
|
||
var calls []provider.ToolCall
|
||
var responsesItems []json.RawMessage
|
||
var partialCalls []provider.ToolCall
|
||
var usage *provider.Usage
|
||
var partialToolStarted bool
|
||
var maxArgChars int
|
||
var lastArgProgress time.Time
|
||
// collect packages the stream state accumulated so far; stored is the
|
||
// finishReasoning output that becomes the round-tripped reasoning.
|
||
collect := func(stored string, err error) streamedTurn {
|
||
return streamedTurn{
|
||
text: text.String(), reasoning: stored, signature: signature,
|
||
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
||
calls: calls, responsesItems: responsesItems, usage: usage,
|
||
partialToolStarted: partialToolStarted, partialCalls: partialCalls,
|
||
maxArgChars: maxArgChars, err: err,
|
||
}
|
||
}
|
||
finishReasoning := func() (stored, display string) {
|
||
original := reasoning.String()
|
||
display = original
|
||
if transformReasoning && original != "" {
|
||
display = a.hooks.PostLLMCall(ctx, original, turn)
|
||
if display != "" {
|
||
sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
|
||
}
|
||
}
|
||
stored = display
|
||
providerBound := signature != "" || reasoningID != "" || reasoningStatus != ""
|
||
if providerBound || provider.RequiresReasoningRoundTrip(a.prov) || (len(calls) > 0 && provider.RequiresToolCallReasoning(a.prov)) {
|
||
stored = original
|
||
}
|
||
return stored, display
|
||
}
|
||
for {
|
||
var chunk provider.Chunk
|
||
select {
|
||
case <-ctx.Done():
|
||
stored, _ := finishReasoning()
|
||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
return collect(stored, ctx.Err())
|
||
case c, ok := <-ch:
|
||
if !ok {
|
||
if err := ctx.Err(); err != nil {
|
||
stored, _ := finishReasoning()
|
||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
return collect(stored, err)
|
||
}
|
||
stored, display := finishReasoning()
|
||
// provider.response: extensions rule on the assembled terminal
|
||
// response before it is persisted. A replacement becomes the
|
||
// visible assistant turn (the user's transcript); a block fails
|
||
// the turn.
|
||
providerSignature := signature
|
||
finalText, finalReasoning, signature, calls, usage, err := a.interceptProviderResponse(
|
||
ctx, text.String(), stored, signature, calls, usage)
|
||
if err != nil {
|
||
return streamedTurn{partialToolStarted: partialToolStarted, partialCalls: partialCalls, maxArgChars: maxArgChars, err: err}
|
||
}
|
||
// Responses reasoning IDs/status and Anthropic signatures are
|
||
// provider-bound metadata. Never attach the provider's metadata
|
||
// to reasoning that an extension replaced.
|
||
if finalReasoning != stored || signature != providerSignature {
|
||
reasoningID, reasoningStatus = "", ""
|
||
}
|
||
if finalReasoning != stored {
|
||
// The extension replaced the reasoning: what is persisted
|
||
// and what the closing Message event re-renders must agree.
|
||
display = finalReasoning
|
||
}
|
||
if finalText != "" || display != "" {
|
||
sink.Emit(event.Event{
|
||
Kind: event.Message,
|
||
Text: DisplayAssistantText(finalText),
|
||
Reasoning: display,
|
||
})
|
||
}
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
// A clean terminal never reports partialToolStarted: the calls
|
||
// slice is now authoritative and the partial cards were merged.
|
||
return streamedTurn{
|
||
text: finalText, reasoning: finalReasoning, signature: signature,
|
||
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
||
calls: calls, responsesItems: responsesItems, usage: usage,
|
||
partialCalls: partialCalls, maxArgChars: maxArgChars,
|
||
}
|
||
}
|
||
chunk = c
|
||
}
|
||
switch chunk.Type {
|
||
case provider.ChunkReasoning:
|
||
reasoning.WriteString(chunk.Text)
|
||
if chunk.Signature != "" {
|
||
signature = chunk.Signature
|
||
}
|
||
// 元数据 chunk(空 Text):reasoning item id/status 贯通
|
||
// SSE → session → 下一轮回传(评审 #7234 第 1 点)。
|
||
if chunk.ReasoningID != "" {
|
||
reasoningID = chunk.ReasoningID
|
||
}
|
||
if chunk.ReasoningStatus != "" {
|
||
reasoningStatus = chunk.ReasoningStatus
|
||
}
|
||
if chunk.Text != "" && !transformReasoning {
|
||
sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
|
||
}
|
||
if a.reasoningByteLimit > 0 && reasoning.Len() > a.reasoningByteLimit {
|
||
stored, _ := finishReasoning()
|
||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), finishReasonClientReasoningLimit)
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
a.lastUsage.Store(usage)
|
||
return collect(stored, errReasoningByteLimitExceeded)
|
||
}
|
||
case provider.ChunkText:
|
||
text.WriteString(chunk.Text)
|
||
sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text})
|
||
case provider.ChunkToolCallStart:
|
||
partialToolStarted = true
|
||
// Surface the tool card as soon as the call begins — before its
|
||
// (possibly large) arguments finish streaming — so the user sees it
|
||
// working instead of a stall. executeBatch emits the full dispatch
|
||
// (with args) once the call completes; the frontend merges by ID.
|
||
if tc := chunk.ToolCall; tc != nil {
|
||
partialCalls = upsertPartialToolCall(partialCalls, *tc)
|
||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
||
ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, AttemptID: attemptID,
|
||
}})
|
||
}
|
||
case provider.ChunkToolCallArgsDelta:
|
||
partialToolStarted = true
|
||
// Liveness ticks while a large argument payload streams: re-emit the
|
||
// partial dispatch with the cumulative size (time-throttled) so the
|
||
// UI can show progress instead of a dead counter for the duration of
|
||
// a 30KB write_file body.
|
||
if chunk.ArgChars > maxArgChars {
|
||
maxArgChars = chunk.ArgChars
|
||
}
|
||
if tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
|
||
partialCalls = upsertPartialToolCall(partialCalls, *tc)
|
||
lastArgProgress = time.Now()
|
||
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
||
ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, ArgChars: chunk.ArgChars, AttemptID: attemptID,
|
||
}})
|
||
}
|
||
case provider.ChunkToolCall:
|
||
partialToolStarted = true
|
||
if chunk.ToolCall != nil {
|
||
calls = append(calls, *chunk.ToolCall)
|
||
partialCalls = upsertPartialToolCall(partialCalls, *chunk.ToolCall)
|
||
if n := len(chunk.ToolCall.Arguments); n > maxArgChars {
|
||
maxArgChars = n
|
||
}
|
||
}
|
||
case provider.ChunkResponsesItem:
|
||
if len(chunk.ResponsesItem) > 0 {
|
||
responsesItems = append(responsesItems, append(json.RawMessage(nil), chunk.ResponsesItem...))
|
||
}
|
||
case provider.ChunkUsage:
|
||
usage = chunk.Usage
|
||
a.lastUsage.Store(chunk.Usage)
|
||
a.sessCacheHit.Add(int64(chunk.Usage.CacheHitTokens))
|
||
a.sessCacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
|
||
case provider.ChunkError:
|
||
if provider.IsStreamInterrupted(chunk.Err) {
|
||
stored, _ := finishReasoning()
|
||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
st := collect(stored, chunk.Err)
|
||
st.interrupted = true
|
||
return st
|
||
}
|
||
stored, _ := finishReasoning()
|
||
if errors.Is(chunk.Err, context.Canceled) || errors.Is(chunk.Err, context.DeadlineExceeded) {
|
||
usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
|
||
}
|
||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||
return collect(stored, chunk.Err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func bestEffortStreamUsage(current *provider.Usage, textBytes, reasoningBytes int, finishReason string) *provider.Usage {
|
||
if current == nil && textBytes == 0 && reasoningBytes == 0 {
|
||
return nil
|
||
}
|
||
var usage provider.Usage
|
||
if current != nil {
|
||
usage = *current
|
||
}
|
||
if finishReason != "" {
|
||
usage.FinishReason = finishReason
|
||
}
|
||
reasoningTokens := estimateTokensFromBytes(reasoningBytes)
|
||
textTokens := estimateTokensFromBytes(textBytes)
|
||
completionTokens := reasoningTokens + textTokens
|
||
if usage.ReasoningTokens < reasoningTokens {
|
||
usage.ReasoningTokens = reasoningTokens
|
||
usage.Estimated = true
|
||
}
|
||
if usage.CompletionTokens < completionTokens {
|
||
usage.CompletionTokens = completionTokens
|
||
usage.Estimated = true
|
||
}
|
||
if minTotal := usage.PromptTokens + usage.CompletionTokens; usage.TotalTokens < minTotal {
|
||
usage.TotalTokens = minTotal
|
||
usage.Estimated = true
|
||
}
|
||
return &usage
|
||
}
|
||
|
||
func estimateTokensFromBytes(n int) int {
|
||
if n <= 0 {
|
||
return 0
|
||
}
|
||
tokens := n / 4
|
||
if n%4 != 0 {
|
||
tokens++
|
||
}
|
||
if tokens <= 0 {
|
||
return 1
|
||
}
|
||
return tokens
|
||
}
|
||
|
||
func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) []provider.ToolCall {
|
||
for i := range calls {
|
||
if call.ID != "" && calls[i].ID == call.ID {
|
||
calls[i] = call
|
||
return calls
|
||
}
|
||
}
|
||
return append(calls, call)
|
||
}
|
||
|
||
func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provider.ToolCall, pending bool, workDurationMs int64) {
|
||
displayCalls := make([]provider.ToolCall, 0, len(calls))
|
||
interrupted := make([]string, 0, len(calls))
|
||
seen := make(map[string]struct{}, len(calls))
|
||
for _, call := range calls {
|
||
name := strings.TrimSpace(call.Name)
|
||
key := call.ID + "\x00" + name
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
displayCalls = append(displayCalls, provider.ToolCall{ID: call.ID, Name: name})
|
||
if name != "" {
|
||
interrupted = append(interrupted, name)
|
||
}
|
||
}
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleTool,
|
||
Content: text,
|
||
ReasoningContent: reasoning,
|
||
ToolCalls: displayCalls,
|
||
ToolCallID: provider.LocalOnlyToolID,
|
||
Name: provider.LocalOnlyToolName,
|
||
WorkDurationMs: workDurationMs,
|
||
LocalOnly: true,
|
||
InterruptedTurn: &provider.InterruptedTurnRecovery{
|
||
Pending: pending,
|
||
InterruptedTools: interrupted,
|
||
DroppedPartialText: strings.TrimSpace(text) != "",
|
||
DroppedPartialReasoning: strings.TrimSpace(reasoning) != "",
|
||
},
|
||
})
|
||
}
|
||
|
||
func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
|
||
return CaptureShape(a.systemPrompt(), schemas, a.session.RewriteVersion())
|
||
}
|
||
|
||
func (a *Agent) systemPrompt() string {
|
||
var b strings.Builder
|
||
for _, m := range a.session.Messages {
|
||
if m.Role != provider.RoleSystem {
|
||
continue
|
||
}
|
||
if b.Len() > 0 {
|
||
b.WriteByte('\n')
|
||
}
|
||
b.WriteString(m.Content)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// batchExecution is the result of one provider tool-call batch.
|
||
type batchExecution struct {
|
||
results []string
|
||
images [][]string
|
||
executions []*tool.ShellExecution
|
||
recoveryStopTurn bool
|
||
recoveryStopReason string
|
||
}
|
||
|
||
// executeBatch dispatches one model turn's tool calls. A ToolDispatch event is
|
||
// emitted for every call up front, in call order, so a frontend can show the
|
||
// timeline chronologically. Contiguous known ReadOnly calls fan out across
|
||
// goroutines; unknown and writer calls run as single-call serial segments so
|
||
// write/read ordering stays provider-ordered. ToolResult events are emitted
|
||
// after the batch in call order, so emission stays serial even when execution
|
||
// parallelised. Images are aligned by index with results.
|
||
func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) batchExecution {
|
||
// The assistant message already stored this slice in Session. Keep execution
|
||
// state separate so refreshing a dependent preview never mutates shared
|
||
// session memory outside Session's lock.
|
||
calls = append([]provider.ToolCall(nil), calls...)
|
||
for _, c := range calls {
|
||
a.emitFullToolDispatch(c, false)
|
||
}
|
||
|
||
results := make([]string, len(calls))
|
||
outcomes := make([]toolOutcome, len(calls))
|
||
durations := make([]int64, len(calls))
|
||
completedStepInBatch := false
|
||
// Snapshot the receipt count before the batch runs: if a loop guard fires
|
||
// for this batch, successes recorded during it (a mixed batch where only one
|
||
// call was guard-blocked) must already count as progress against the pass.
|
||
receiptMark := 0
|
||
if a.evidence != nil {
|
||
receiptMark = a.evidence.Len()
|
||
}
|
||
// Full dispatches are prepared against the batch's initial file state. After
|
||
// one writer runs, a dependent later writer may only become previewable (or
|
||
// its original preview may become stale). Refresh even after a failed writer:
|
||
// commands and filesystem calls can mutate disk before reporting an error.
|
||
// The first writer stays on the single-preview fast path.
|
||
earlierWriterRan := false
|
||
surfaceWriters := make([]bool, len(calls))
|
||
run := func(i int) {
|
||
t, _, ambiguous := a.tools.ResolveCall(calls[i].Name)
|
||
known := t != nil && len(ambiguous) == 0
|
||
writer := known && !t.ReadOnly()
|
||
surfaceWriters[i] = writer
|
||
if earlierWriterRan && writer {
|
||
if refreshed, changed := refreshCurrentFileDiff(t, calls[i]); changed {
|
||
calls[i] = refreshed
|
||
a.session.UpdateToolCallPreview(refreshed)
|
||
a.emitFullToolDispatch(refreshed, true)
|
||
}
|
||
}
|
||
start := time.Now()
|
||
if calls[i].Name == "complete_step" && completedStepInBatch {
|
||
output := "blocked: only one successful complete_step is allowed per tool-call round. Continue from the newly promoted in_progress todo in the next round instead of batching sign-offs."
|
||
outcomes[i] = toolOutcome{output: output, blocked: true, errMsg: "blocked: complete_step sign-offs must be serial"}
|
||
if a.evidence != nil {
|
||
a.evidence.Record(evidence.ReceiptFromToolCall(calls[i].Name, json.RawMessage(calls[i].Arguments), false, true))
|
||
}
|
||
durations[i] = time.Since(start).Milliseconds()
|
||
results[i] = output
|
||
return
|
||
}
|
||
outcomes[i] = a.executeOne(ctx, calls[i])
|
||
if outcomes[i].resolved {
|
||
readOnly := outcomes[i].resolvedReadOnly
|
||
calls[i].ResolvedName = outcomes[i].resolvedName
|
||
calls[i].CapabilityID = outcomes[i].capabilityID
|
||
calls[i].ResolvedReadOnly = &readOnly
|
||
surfaceWriters[i] = !readOnly
|
||
}
|
||
if calls[i].Name == "complete_step" && outcomes[i].errMsg == "" {
|
||
completedStepInBatch = true
|
||
}
|
||
durations[i] = time.Since(start).Milliseconds()
|
||
results[i] = outcomes[i].output
|
||
}
|
||
finalize := func(i int) {
|
||
if calls[i].ResolvedReadOnly != nil {
|
||
a.session.UpdateToolCallResolution(calls[i])
|
||
a.emitResolvedToolDispatch(calls[i])
|
||
}
|
||
if surfaceWriters[i] || (outcomes[i].resolved && !outcomes[i].resolvedReadOnly) {
|
||
earlierWriterRan = true
|
||
}
|
||
}
|
||
cancelled := false
|
||
markCancelled := func(start int) {
|
||
errMsg := context.Canceled.Error()
|
||
if err := ctx.Err(); err != nil {
|
||
errMsg = err.Error()
|
||
}
|
||
output := "cancelled: context cancelled before execution"
|
||
for j := start; j < len(calls); j++ {
|
||
results[j] = output
|
||
outcomes[j] = toolOutcome{output: output, errMsg: errMsg}
|
||
}
|
||
cancelled = true
|
||
}
|
||
|
||
// recoveryBatchStop blocks remaining tools after Episode budgets are
|
||
// exhausted so tool-call / result pairs stay complete for the provider.
|
||
recoveryBatchStop := false
|
||
recoveryStopReason := ""
|
||
markRecoveryStopped := func(start int, reason string) {
|
||
msg := "blocked: Auto recovery paused this turn; do not call more tools. Summarize completed work for the user."
|
||
for j := start; j < len(calls); j++ {
|
||
if results[j] != "" {
|
||
continue
|
||
}
|
||
results[j] = msg
|
||
outcomes[j] = toolOutcome{
|
||
output: msg,
|
||
blocked: true,
|
||
errMsg: firstLine(msg),
|
||
recoveryStopTurn: true,
|
||
recoveryStopReason: reason,
|
||
}
|
||
}
|
||
recoveryBatchStop = true
|
||
if reason != "" {
|
||
recoveryStopReason = reason
|
||
}
|
||
}
|
||
|
||
// mutationBatchStop is the deterministic dependency barrier: after any
|
||
// mutating call fails or is blocked, later mutating and verification calls
|
||
// in the same provider batch are skipped (not_run/dependency). Host-proven
|
||
// read-only diagnosis may still run. executeOne also re-checks after proxy
|
||
// resolution so use_capability cannot bypass this pass.
|
||
mutationBatchStop := false
|
||
a.mutationDependencyBarrier.Store(false)
|
||
markDependencySkipped := func(start int) {
|
||
a.mutationDependencyBarrier.Store(true)
|
||
for j := start; j < len(calls); j++ {
|
||
if results[j] != "" {
|
||
continue
|
||
}
|
||
// Pre-classify when statically certain. Proxies and ambiguous
|
||
// targets fall through to run() so executeOne can resolve the real
|
||
// target and re-apply the barrier before Commit/Execute.
|
||
if !batchCallStaticallySkippable(a, calls[j]) {
|
||
continue
|
||
}
|
||
isVerification := calls[j].Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[j].Arguments)))
|
||
msg := "blocked: skipped because an earlier modification in this tool batch failed or was blocked. " +
|
||
"Fix or re-run the failed change first; verification was not executed."
|
||
var ex *tool.ShellExecution
|
||
if calls[j].Name == "bash" {
|
||
ex = &tool.ShellExecution{
|
||
Kind: "shell",
|
||
State: tool.ShellStateNotRun,
|
||
FailurePhase: tool.ShellPhaseDependency,
|
||
MutationRisk: tool.ShellMutationNotStarted,
|
||
Verification: tool.ShellVerificationNotVerification,
|
||
}
|
||
if isVerification {
|
||
ex.Verification = tool.ShellVerificationNotRun
|
||
}
|
||
if t, _, amb := a.tools.ResolveCall(calls[j].Name); t != nil && len(amb) == 0 {
|
||
if bt, ok := t.(tool.DetailedExecutor); ok {
|
||
if desc := bt.ExecutionDescriptor(json.RawMessage(calls[j].Arguments)); desc != nil {
|
||
ex.Shell = desc.Shell
|
||
ex.ShellVersion = desc.ShellVersion
|
||
ex.Platform = desc.Platform
|
||
ex.SupportsAndAnd = desc.SupportsAndAnd
|
||
}
|
||
}
|
||
}
|
||
}
|
||
results[j] = msg
|
||
outcomes[j] = toolOutcome{
|
||
output: msg,
|
||
blocked: true,
|
||
errMsg: firstLine(msg),
|
||
execution: ex,
|
||
}
|
||
durations[j] = 0
|
||
}
|
||
mutationBatchStop = true
|
||
}
|
||
|
||
for _, batch := range partitionToolCalls(a.tools, calls) {
|
||
if ctx.Err() != nil {
|
||
markCancelled(batch.start)
|
||
break
|
||
}
|
||
if recoveryBatchStop {
|
||
markRecoveryStopped(batch.start, recoveryStopReason)
|
||
break
|
||
}
|
||
if batch.parallel && batch.end-batch.start > 1 {
|
||
// Parallel segments are read-only by construction; no mutation barrier.
|
||
ranUntil := runParallel(ctx, batch.start, batch.end, run)
|
||
for i := batch.start; i < ranUntil; i++ {
|
||
finalize(i)
|
||
}
|
||
// After parallel execution completes, check if context was cancelled.
|
||
// The individual tool executions should have detected ctx.Done(), but
|
||
// we verify here to ensure we don't continue to subsequent batches.
|
||
if ctx.Err() != nil {
|
||
markCancelled(ranUntil)
|
||
break
|
||
}
|
||
for i := batch.start; i < batch.end; i++ {
|
||
if outcomes[i].recoveryStopTurn {
|
||
recoveryBatchStop = true
|
||
recoveryStopReason = outcomes[i].recoveryStopReason
|
||
markRecoveryStopped(batch.end, recoveryStopReason)
|
||
break
|
||
}
|
||
}
|
||
if recoveryBatchStop {
|
||
break
|
||
}
|
||
continue
|
||
}
|
||
for i := batch.start; i < batch.end; i++ {
|
||
// Before executing the next tool, check if context was cancelled.
|
||
// This prevents starting new tools when a previous tool's execution
|
||
// triggered cancellation.
|
||
if ctx.Err() != nil {
|
||
markCancelled(i)
|
||
break
|
||
}
|
||
if recoveryBatchStop {
|
||
markRecoveryStopped(i, recoveryStopReason)
|
||
break
|
||
}
|
||
if mutationBatchStop {
|
||
// Fill dependency skips for remaining mutating/verify calls, then
|
||
// allow any residual read-only diagnosis to run individually.
|
||
if results[i] != "" {
|
||
continue
|
||
}
|
||
t, _, ambiguous := a.tools.ResolveCall(calls[i].Name)
|
||
known := t != nil && len(ambiguous) == 0
|
||
readOnly := known && t.ReadOnly()
|
||
if calls[i].Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(calls[i].Arguments)) {
|
||
readOnly = true
|
||
}
|
||
isVerification := calls[i].Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[i].Arguments)))
|
||
mutates := evidence.ToolCallMutates(calls[i].Name, json.RawMessage(calls[i].Arguments), readOnly)
|
||
if mutates || isVerification {
|
||
markDependencySkipped(i)
|
||
// markDependencySkipped fills this index; move on.
|
||
if results[i] != "" {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
if results[i] != "" {
|
||
// Pre-filled dependency skip.
|
||
finalize(i)
|
||
continue
|
||
}
|
||
run(i)
|
||
finalize(i)
|
||
if outcomes[i].recoveryStopTurn {
|
||
recoveryBatchStop = true
|
||
recoveryStopReason = outcomes[i].recoveryStopReason
|
||
markRecoveryStopped(i+1, recoveryStopReason)
|
||
break
|
||
}
|
||
// Mutation/verification failure barrier for the rest of this batch.
|
||
if batchCallIsMutatingFailure(a, calls[i], outcomes[i]) {
|
||
mutationBatchStop = true
|
||
markDependencySkipped(i + 1)
|
||
}
|
||
// After each tool execution, also check if the context was cancelled.
|
||
// If so, stop executing remaining tools and return immediately so
|
||
// the agent loop can detect the cancellation and exit.
|
||
if ctx.Err() != nil {
|
||
markCancelled(i + 1)
|
||
break
|
||
}
|
||
}
|
||
if cancelled || recoveryBatchStop {
|
||
break
|
||
}
|
||
}
|
||
|
||
for i, c := range calls {
|
||
o := outcomes[i]
|
||
t, _, ambiguous := a.tools.ResolveCall(c.Name)
|
||
ok := t != nil && len(ambiguous) == 0
|
||
readOnly := ok && t.ReadOnly()
|
||
if c.ResolvedReadOnly != nil {
|
||
readOnly = *c.ResolvedReadOnly
|
||
}
|
||
tr := event.Tool{
|
||
ID: c.ID,
|
||
Name: c.Name,
|
||
Args: c.Arguments,
|
||
ResolvedName: c.ResolvedName,
|
||
CapabilityID: c.CapabilityID,
|
||
Output: o.output,
|
||
Err: o.errMsg,
|
||
ReadOnly: readOnly,
|
||
Truncated: o.truncated,
|
||
DurationMs: durations[i],
|
||
Execution: toEventShellExecution(o.execution, durations[i]),
|
||
}
|
||
a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: tr})
|
||
if o.truncated && o.truncMsg != "" {
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
|
||
}
|
||
}
|
||
if !cancelled {
|
||
a.applyStormBreaker(calls, outcomes, results, receiptMark)
|
||
}
|
||
images := make([][]string, len(calls))
|
||
executions := make([]*tool.ShellExecution, len(calls))
|
||
for i := range outcomes {
|
||
images[i] = outcomes[i].images
|
||
executions[i] = outcomes[i].execution
|
||
if outcomes[i].recoveryStopTurn {
|
||
recoveryBatchStop = true
|
||
if outcomes[i].recoveryStopReason != "" {
|
||
recoveryStopReason = outcomes[i].recoveryStopReason
|
||
}
|
||
}
|
||
}
|
||
return batchExecution{
|
||
results: results,
|
||
images: images,
|
||
executions: executions,
|
||
recoveryStopTurn: recoveryBatchStop,
|
||
recoveryStopReason: recoveryStopReason,
|
||
}
|
||
}
|
||
|
||
func toEventShellExecution(in *tool.ShellExecution, durationMs int64) *event.ShellExecution {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
out := &event.ShellExecution{
|
||
Kind: in.Kind,
|
||
Shell: in.Shell,
|
||
ShellVersion: in.ShellVersion,
|
||
Platform: in.Platform,
|
||
SupportsAndAnd: in.SupportsAndAnd,
|
||
State: in.State,
|
||
FailurePhase: in.FailurePhase,
|
||
OutputTail: in.OutputTail,
|
||
MutationRisk: in.MutationRisk,
|
||
Verification: in.Verification,
|
||
DurationMs: in.DurationMs,
|
||
}
|
||
if out.DurationMs == 0 && durationMs > 0 {
|
||
out.DurationMs = durationMs
|
||
}
|
||
if in.ExitCode != nil {
|
||
code := *in.ExitCode
|
||
out.ExitCode = &code
|
||
}
|
||
return out
|
||
}
|
||
|
||
func toProviderToolExecution(in *tool.ShellExecution) *provider.ToolExecution {
|
||
if in == nil {
|
||
return nil
|
||
}
|
||
out := &provider.ToolExecution{
|
||
Kind: in.Kind,
|
||
Shell: in.Shell,
|
||
ShellVersion: in.ShellVersion,
|
||
Platform: in.Platform,
|
||
SupportsAndAnd: in.SupportsAndAnd,
|
||
State: in.State,
|
||
FailurePhase: in.FailurePhase,
|
||
OutputTail: in.OutputTail,
|
||
MutationRisk: in.MutationRisk,
|
||
Verification: in.Verification,
|
||
DurationMs: in.DurationMs,
|
||
}
|
||
if in.ExitCode != nil {
|
||
code := *in.ExitCode
|
||
out.ExitCode = &code
|
||
}
|
||
return out
|
||
}
|
||
|
||
// batchCallIsMutatingFailure reports whether a finished call was a mutation
|
||
// (file write / non-readonly bash mutation) that failed or was blocked, so later
|
||
// mutations and verifications in the same batch must not run.
|
||
func batchCallIsMutatingFailure(a *Agent, call provider.ToolCall, o toolOutcome) bool {
|
||
if o.errMsg == "" && !o.blocked {
|
||
return false
|
||
}
|
||
readOnly := false
|
||
t, _, ambiguous := a.tools.ResolveCall(call.Name)
|
||
known := t != nil && len(ambiguous) == 0
|
||
if known {
|
||
readOnly = t.ReadOnly()
|
||
}
|
||
if call.ResolvedReadOnly != nil {
|
||
readOnly = *call.ResolvedReadOnly
|
||
}
|
||
if o.resolved {
|
||
readOnly = o.resolvedReadOnly
|
||
}
|
||
if call.Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(call.Arguments)) {
|
||
readOnly = true
|
||
}
|
||
// Verification failures do not open the dependency barrier by themselves —
|
||
// only a failed modification does.
|
||
if call.Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments))) {
|
||
return false
|
||
}
|
||
// Resolved writers (including MCP targets behind use_capability) count even
|
||
// when the provider-visible proxy advertised ReadOnly.
|
||
if o.resolved && !o.resolvedReadOnly {
|
||
return true
|
||
}
|
||
if evidence.ToolCallMutates(call.Name, json.RawMessage(call.Arguments), readOnly) {
|
||
return true
|
||
}
|
||
// Fail closed only for a target the host could not classify at all. A blanket
|
||
// !readOnly fallback here would re-admit exactly the writers ToolCallMutates
|
||
// deliberately exempts (todo_write, complete_step, ask, bash_output, wait and
|
||
// the other non-mutation meta tools): a failed todo update would then block
|
||
// every real edit left in the batch. Resolved writer proxies already returned
|
||
// true above, so narrowing this does not reopen the use_capability path.
|
||
return !known
|
||
}
|
||
|
||
// batchCallStaticallySkippable reports whether a remaining call can be marked
|
||
// not_run/dependency without resolving a proxy. Proxies and unknown tools
|
||
// return false so executeOne can resolve the real target first.
|
||
func batchCallStaticallySkippable(a *Agent, call provider.ToolCall) bool {
|
||
t, _, ambiguous := a.tools.ResolveCall(call.Name)
|
||
if t == nil || len(ambiguous) > 0 {
|
||
// Unknown / ambiguous: fail closed via executeOne path.
|
||
return false
|
||
}
|
||
// Proxy resolution may consult a live connected capability and its result
|
||
// can change between calls. Do not resolve here merely to pre-fill a skip:
|
||
// executeOne resolves exactly once, then applyMutationDependencyBarrier
|
||
// classifies the real target before Commit or Execute.
|
||
if _, ok := t.(tool.CallResolver); ok {
|
||
return false
|
||
}
|
||
readOnly := t.ReadOnly()
|
||
if call.Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(call.Arguments)) {
|
||
readOnly = true
|
||
}
|
||
isVerification := call.Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments)))
|
||
if isVerification {
|
||
return true
|
||
}
|
||
return !readOnly || evidence.ToolCallMutates(call.Name, json.RawMessage(call.Arguments), readOnly)
|
||
}
|
||
|
||
func (a *Agent) emitFullToolDispatch(c provider.ToolCall, refreshed bool) {
|
||
t, _, ambiguous := a.tools.ResolveCall(c.Name)
|
||
ok := t != nil && len(ambiguous) == 0
|
||
ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.Arguments, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed}
|
||
ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
|
||
if ok && ev.Diff == "" && ev.Added == 0 && ev.Removed == 0 {
|
||
if ch, ok := tool.PreviewChange(t, json.RawMessage(c.Arguments)); ok {
|
||
ev.FileDiff = event.FileDiff{Diff: ch.Diff, Added: ch.Added, Removed: ch.Removed}
|
||
}
|
||
}
|
||
if ok {
|
||
if pr, ok := t.(interface {
|
||
ResolveProfile(json.RawMessage) *event.Profile
|
||
}); ok {
|
||
ev.Profile = pr.ResolveProfile(json.RawMessage(c.Arguments))
|
||
}
|
||
}
|
||
a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: ev})
|
||
}
|
||
|
||
// emitResolvedToolDispatch upserts the real target classification of a stable
|
||
// proxy call without changing the provider-visible Name/Args. Append-only sinks
|
||
// ignore Refreshed events; stateful frontends replace the existing card by ID.
|
||
func (a *Agent) emitResolvedToolDispatch(c provider.ToolCall) {
|
||
if c.ResolvedReadOnly == nil {
|
||
return
|
||
}
|
||
if c.ResolvedName != "" && c.ResolvedName != c.Name {
|
||
EmitProxyAudit(a.sink, tool.ResolvedCall{
|
||
DisplayName: c.Name,
|
||
TargetName: c.ResolvedName,
|
||
CapabilityID: c.CapabilityID,
|
||
})
|
||
}
|
||
a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
||
ID: c.ID,
|
||
Name: c.Name,
|
||
Args: c.Arguments,
|
||
ResolvedName: c.ResolvedName,
|
||
CapabilityID: c.CapabilityID,
|
||
ReadOnly: *c.ResolvedReadOnly,
|
||
Refreshed: true,
|
||
FileDiff: event.FileDiff{
|
||
Diff: c.Diff, Added: c.Added, Removed: c.Removed,
|
||
},
|
||
}})
|
||
}
|
||
|
||
// refreshCurrentFileDiff recomputes a writer preview against the state left by
|
||
// earlier successful writers in the same provider batch. Preview failures clear
|
||
// any stale initial diff; a later Execute will then fail or ask for recovery
|
||
// without presenting the user with a preview that no longer describes disk.
|
||
func refreshCurrentFileDiff(t tool.Tool, call provider.ToolCall) (provider.ToolCall, bool) {
|
||
pv, ok := t.(tool.Previewer)
|
||
if !ok {
|
||
return call, false
|
||
}
|
||
refreshed := call
|
||
refreshed.Diff = ""
|
||
refreshed.Added = 0
|
||
refreshed.Removed = 0
|
||
if change, err := pv.Preview(json.RawMessage(call.Arguments)); err == nil {
|
||
refreshed.Diff = change.Diff
|
||
refreshed.Added = change.Added
|
||
refreshed.Removed = change.Removed
|
||
}
|
||
changed := refreshed.Diff != call.Diff || refreshed.Added != call.Added || refreshed.Removed != call.Removed
|
||
return refreshed, changed
|
||
}
|
||
|
||
func (a *Agent) withPreviewFileDiffs(calls []provider.ToolCall) []provider.ToolCall {
|
||
if len(calls) == 0 {
|
||
return calls
|
||
}
|
||
out := make([]provider.ToolCall, len(calls))
|
||
copy(out, calls)
|
||
for i := range out {
|
||
if out[i].Diff != "" || out[i].Added != 0 || out[i].Removed != 0 {
|
||
continue
|
||
}
|
||
t, _, ambiguous := a.tools.ResolveCall(out[i].Name)
|
||
ok := t != nil && len(ambiguous) == 0
|
||
if !ok {
|
||
continue
|
||
}
|
||
if ch, ok := tool.PreviewChange(t, json.RawMessage(out[i].Arguments)); ok {
|
||
out[i].Diff = ch.Diff
|
||
out[i].Added = ch.Added
|
||
out[i].Removed = ch.Removed
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
type toolCallBatch struct {
|
||
start int
|
||
end int
|
||
parallel bool
|
||
}
|
||
|
||
// partitionToolCalls keeps provider order while letting contiguous known
|
||
// read-only tools run together. Unknown and writer tools are single-call serial
|
||
// batches so they cannot reorder around reads or produce surprising errors.
|
||
// complete_step and todo_write read the turn's evidence ledger. wait and
|
||
// bash_output can merge a background task's receipts into that ledger. These
|
||
// evidence-sensitive tools never join a parallel run, so provider order stays
|
||
// receipt order. use_capability is always serial because its provider-visible
|
||
// read-only surface can resolve to a real MCP writer only inside executeOne;
|
||
// batching it as a reader would let multiple database/API mutations race.
|
||
func partitionToolCalls(r *tool.Registry, calls []provider.ToolCall) []toolCallBatch {
|
||
var batches []toolCallBatch
|
||
for i := 0; i < len(calls); {
|
||
if parallelisable(r, calls[i].Name) {
|
||
start := i
|
||
i++
|
||
for i < len(calls) && parallelisable(r, calls[i].Name) {
|
||
i++
|
||
}
|
||
batches = append(batches, toolCallBatch{start: start, end: i, parallel: true})
|
||
continue
|
||
}
|
||
batches = append(batches, toolCallBatch{start: i, end: i + 1})
|
||
i++
|
||
}
|
||
return batches
|
||
}
|
||
|
||
func parallelisable(r *tool.Registry, name string) bool {
|
||
switch name {
|
||
case "complete_step", "todo_write", "wait", "bash_output", "use_capability":
|
||
return false
|
||
}
|
||
t, _, ambiguous := r.ResolveCall(name)
|
||
return t != nil && len(ambiguous) == 0 && t.ReadOnly()
|
||
}
|
||
|
||
func runParallel(ctx context.Context, start, end int, run func(int)) int {
|
||
const maxParallel = 8
|
||
sem := make(chan struct{}, maxParallel)
|
||
var wg sync.WaitGroup
|
||
ranUntil := start
|
||
launch:
|
||
for i := start; i < end; i++ {
|
||
if ctx.Err() != nil {
|
||
break
|
||
}
|
||
select {
|
||
case sem <- struct{}{}:
|
||
case <-ctx.Done():
|
||
break launch
|
||
}
|
||
if ctx.Err() != nil {
|
||
<-sem
|
||
break
|
||
}
|
||
|
||
wg.Add(1)
|
||
ranUntil = i + 1
|
||
go func() {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
run(i)
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
return ranUntil
|
||
}
|
||
|
||
// stormBreakThreshold is how many times in a row the same tool may fail the same
|
||
// way before the loop stops echoing the raw error back and instead returns a
|
||
// directive to change approach. Two natural self-corrections are healthy; the
|
||
// third identical failure is a death-spiral — the dominant case being a tool call
|
||
// whose arguments are truncated at the output-token ceiling, which the model then
|
||
// re-emits (re-worded but still over-long), truncating the same way again.
|
||
const stormBreakThreshold = 3
|
||
|
||
// repeatSuccessBreakThreshold is how many identical write-like successes the
|
||
// agent allows before refusing another copy in the same user turn. Two gives the
|
||
// model room for a natural self-correction; the third repeat is usually a
|
||
// no-op/write loop and should be redirected to a different tool or final answer.
|
||
const repeatSuccessBreakThreshold = 2
|
||
|
||
const (
|
||
// todoProgressNudgeRounds is the first adaptive checkpoint. The host asks
|
||
// the model to reassess, but keeps the turn alive so it can recover.
|
||
todoProgressNudgeRounds = 8
|
||
// maxTodoStallRounds pauses only after the reassessment also failed to
|
||
// produce a new completion or unique host-observed work receipt.
|
||
maxTodoStallRounds = 16
|
||
)
|
||
|
||
func todoProgressNudgeMessage(rounds int) string {
|
||
return fmt.Sprintf("Host progress check: the current todo has produced no new completion, unique read, command, or mutation for %d tool-call rounds. Reassess before using more tools: sign off the current item if it is done, narrow the remaining work without replacing the active item, or explain/ask about a real blocker. Do not repeat reads, commands, or writes just to reset this guard.", rounds)
|
||
}
|
||
|
||
// loopGuardBlockErrMsg is the errMsg carried by a repeat-success loop-guard
|
||
// block. applyStormBreaker matches it to arm the final-readiness loop-guard
|
||
// pass, since that guard also invites the model to report the blocker.
|
||
const loopGuardBlockErrMsg = "blocked by loop guard"
|
||
|
||
// applyStormBreaker detects a run of zero-progress turns and, past the
|
||
// threshold, rewrites the model-facing result (results[0]) into a directive to
|
||
// change approach. Two detectors, because a stuck model varies its retries two
|
||
// ways. The signature detector keys on each call's (tool, error/blocker) — not
|
||
// its args — since a stuck model reworks the arguments cosmetically while
|
||
// hitting the same host refusal or failure (see the stormSig field doc). The
|
||
// streak detector counts consecutive turns in which every call was blocked,
|
||
// regardless of shape: rotating tools, reordering a batch, or a blocker whose
|
||
// text varies per attempt escapes the signature but is still zero progress —
|
||
// only a host refusal (not a plain error) proves that, so the streak requires
|
||
// blocked outcomes. Any success resets both. When a guard fires — or when a
|
||
// call in the batch was already blocked by the per-call repeat-success guard —
|
||
// the final-readiness loop-guard pass is armed so the model may report the
|
||
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
|
||
// ultimate backstop; this just keeps the loop from burning that whole budget
|
||
// bouncing off the same host refusals.
|
||
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
|
||
allBlocked := len(outcomes) > 0
|
||
for _, outcome := range outcomes {
|
||
if !outcome.blocked {
|
||
allBlocked = false
|
||
break
|
||
}
|
||
}
|
||
if allBlocked {
|
||
a.blockedTurnStreak++
|
||
} else {
|
||
a.blockedTurnStreak = 0
|
||
}
|
||
for _, outcome := range outcomes {
|
||
if outcome.blocked && outcome.errMsg == loopGuardBlockErrMsg {
|
||
a.armLoopGuardPass(receiptMark)
|
||
break
|
||
}
|
||
}
|
||
|
||
sig, ok := batchStormSignature(calls, outcomes)
|
||
switch {
|
||
case !ok:
|
||
a.stormSig, a.stormCount = "", 0
|
||
case sig != a.stormSig:
|
||
a.stormSig, a.stormCount = sig, 1
|
||
default:
|
||
a.stormCount++
|
||
}
|
||
stormHit := ok && a.stormCount >= stormBreakThreshold
|
||
streakHit := allBlocked && a.blockedTurnStreak >= stormBreakThreshold
|
||
if !stormHit && !streakHit {
|
||
return
|
||
}
|
||
|
||
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
|
||
var guard, detail string
|
||
if stormHit {
|
||
subject := fmt.Sprintf("%q", calls[0].Name)
|
||
short := calls[0].Name
|
||
if len(calls) > 1 {
|
||
subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
|
||
short = fmt.Sprintf("a batch of %d calls", len(calls))
|
||
}
|
||
anyBlocked := false
|
||
for _, outcome := range outcomes {
|
||
if outcome.blocked {
|
||
anyBlocked = true
|
||
break
|
||
}
|
||
}
|
||
action := "failed"
|
||
advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
|
||
if anyBlocked {
|
||
action = "been blocked or failed"
|
||
advice = blockedAdvice
|
||
}
|
||
guard = fmt.Sprintf(
|
||
"[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
|
||
subject, action, a.stormCount, advice)
|
||
detail = fmt.Sprintf(
|
||
"loop guard: %s hit the same host response %d× — nudging the model to change approach",
|
||
short, a.stormCount)
|
||
} else {
|
||
guard = fmt.Sprintf(
|
||
"[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
|
||
a.blockedTurnStreak, blockedAdvice)
|
||
detail = fmt.Sprintf(
|
||
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
|
||
a.blockedTurnStreak)
|
||
}
|
||
results[0] = outcomes[0].output + "\n\n" + guard
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard, Text: loopGuardNoticeText(), Detail: detail})
|
||
a.armLoopGuardPass(receiptMark)
|
||
}
|
||
|
||
func loopGuardNoticeText() string {
|
||
return "The assistant is not making progress; asking it to change approach."
|
||
}
|
||
|
||
// batchStormSignature returns a per-turn fixation signature — each call's
|
||
// (name, error/blocker) in order — and ok=true only when every call errored or
|
||
// was blocked. ok=false (any success) means the turn made progress, so the
|
||
// caller resets the counter. Keying on the host response rather than the args is
|
||
// deliberate: a stuck model reworks the arguments while hitting the same
|
||
// response, so identical-args matching would miss the loop.
|
||
func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (string, bool) {
|
||
if len(calls) == 0 {
|
||
return "", false
|
||
}
|
||
var sb strings.Builder
|
||
for i := range calls {
|
||
if outcomes[i].errMsg == "" {
|
||
return "", false
|
||
}
|
||
sb.WriteString(calls[i].Name)
|
||
sb.WriteByte(0)
|
||
sb.WriteString(outcomes[i].errMsg)
|
||
sb.WriteByte(0)
|
||
}
|
||
return sb.String(), true
|
||
}
|
||
|
||
// toolOutcome is one tool call's result, split into the model-facing output and
|
||
// the display-facing notice bits. errMsg is the short failure reason (empty on
|
||
// success) — a refused call, an unknown tool, or an execution error — so a sink
|
||
// renders the result as failed ("⊘ name <errMsg>" / a red card) instead of OK;
|
||
// blocked narrows that to a refusal (plan mode / permission). truncMsg is set
|
||
// (without the "· " prefix) when the output was head+tailed. images carries
|
||
// data URLs from a tool.ImageTool result; they ride outside output so text
|
||
// truncation can never corrupt an image payload.
|
||
type toolOutcome struct {
|
||
output string
|
||
images []string
|
||
blocked bool
|
||
errMsg string
|
||
truncated bool
|
||
truncMsg string
|
||
resolved bool
|
||
resolvedName string
|
||
capabilityID string
|
||
resolvedReadOnly bool
|
||
// execution is local shell metadata (optional). Provider messages strip it
|
||
// via ModelMessages; UI/event sinks surface it on ToolResult cards.
|
||
execution *tool.ShellExecution
|
||
// recoveryGeneration is the gate generation captured before execution so
|
||
// ObserveResult can ignore stale results after a mode switch.
|
||
recoveryGeneration uint64
|
||
// recoveryStopTurn is set when Auto Episode budgets are exhausted.
|
||
recoveryStopTurn bool
|
||
recoveryStopReason string
|
||
}
|
||
|
||
// completedMCPConnect recognizes a synthetic cache-miss connect call whose
|
||
// background discovery finished after the provider request was serialized. The
|
||
// connect placeholder is intentionally absent once real tools replace it, but
|
||
// the already-advertised call still completed its only job and must not surface
|
||
// as an unknown tool.
|
||
func completedMCPConnect(reg *tool.Registry, name string) (string, bool) {
|
||
server, rawName, ok := tool.SplitMCPName(name)
|
||
if !ok || rawName != "connect" {
|
||
return "", false
|
||
}
|
||
prefix := tool.MCPNamePrefix + server + "__"
|
||
for _, current := range reg.Names() {
|
||
if current != name && strings.HasPrefix(current, prefix) {
|
||
return server, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// recoveryPlanTransition detects structural rewrites of an active canonical
|
||
// task list. Initial plans and progress-only status updates stay on the fast
|
||
// path; changing step identity, order, or hierarchy while work remains is a
|
||
// semantic transition for the independent Auto reviewer.
|
||
func (a *Agent) recoveryPlanTransition(toolName string, args json.RawMessage) (bool, string, string) {
|
||
if a == nil || toolName != "todo_write" || a.planMode.Load() {
|
||
return false, "", ""
|
||
}
|
||
before := a.CanonicalTodoState()
|
||
if len(before) == 0 || len(evidence.IncompleteTodos(before)) == 0 {
|
||
return false, "", ""
|
||
}
|
||
after := evidence.ReceiptFromToolCall("todo_write", args, true, true).Todos
|
||
if len(after) == 0 || evidence.ValidateSerialTodos(after) != nil || !evidence.PreservesCompletedTodoPositions(before, after) {
|
||
// Let todo_write report malformed or invalid state directly; an invalid
|
||
// task list is not a meaningful plan proposal for the reviewer.
|
||
return false, "", ""
|
||
}
|
||
if samePlanStructure(before, after) {
|
||
return false, "", ""
|
||
}
|
||
return true, planReviewText(before), planReviewText(after)
|
||
}
|
||
|
||
func samePlanStructure(a, b []evidence.TodoItem) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i].Level != b[i].Level || normalizePlanStep(a[i].Content) != normalizePlanStep(b[i].Content) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func normalizePlanStep(s string) string {
|
||
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||
}
|
||
|
||
func planReviewText(todos []evidence.TodoItem) string {
|
||
var b strings.Builder
|
||
for i, todo := range todos {
|
||
indent := ""
|
||
if todo.Level == 1 {
|
||
indent = " "
|
||
}
|
||
fmt.Fprintf(&b, "%s%d. %s [%s]", indent, i+1, normalizePlanStep(todo.Content), canonicalTodoStatus(todo.Status))
|
||
if i+1 < len(todos) {
|
||
b.WriteByte('\n')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func recoveryTaskScopeID(deliveryScopeID string, runSeq uint64) string {
|
||
if scope := strings.TrimSpace(deliveryScopeID); scope != "" {
|
||
return "goal:" + scope
|
||
}
|
||
return fmt.Sprintf("turn:%d", runSeq)
|
||
}
|
||
|
||
func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.ResolvedCall) (toolOutcome, bool) {
|
||
if a == nil || !a.readOnlyExecution {
|
||
return toolOutcome{}, false
|
||
}
|
||
block := func(reason string) (toolOutcome, bool) {
|
||
return toolOutcome{
|
||
output: "blocked: read-only agent cannot " + reason,
|
||
blocked: true,
|
||
errMsg: "blocked by read-only execution boundary",
|
||
}, true
|
||
}
|
||
// Destructive MCP is left for the Executor; Planner must not misread this
|
||
// as missing configuration or an unavailable MCP server.
|
||
blockDestructiveForExecutor := func(name string) (toolOutcome, bool) {
|
||
msg := "blocked: MCP capability " + name + " is destructive and is reserved for the Executor. Write the required operation into the plan/handoff so the Coordinator can hand it to the Executor; do not treat this as missing MCP configuration or an unavailable capability."
|
||
return toolOutcome{
|
||
output: msg,
|
||
blocked: true,
|
||
errMsg: "blocked: destructive MCP reserved for executor",
|
||
}, true
|
||
}
|
||
if resolved == nil {
|
||
if a.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
|
||
if !mcpServerAuthorized(visible) {
|
||
return block("execute an MCP capability from an unauthorized server")
|
||
}
|
||
if readOnlyExecutionMCPDestructive(visible) {
|
||
return blockDestructiveForExecutor(visible.Name())
|
||
}
|
||
return toolOutcome{}, false
|
||
}
|
||
if visible == nil || !visible.ReadOnly() {
|
||
if reasoner, ok := visible.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
|
||
return block(reasoner.ReadOnlyExecutionBlockReason())
|
||
}
|
||
return block("execute a state-changing tool")
|
||
}
|
||
if isInstalledMCPTool(visible) && !mcpServerAuthorized(visible) {
|
||
return block("execute a reader from an unauthorized MCP server")
|
||
}
|
||
if readOnlyExecutionMCPDestructive(visible) {
|
||
return block("execute a destructive MCP capability")
|
||
}
|
||
if h, ok := visible.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(visible) {
|
||
return block("start or mutate a host capability")
|
||
}
|
||
return toolOutcome{}, false
|
||
}
|
||
|
||
switch resolved.ProxyAction {
|
||
case "list", "inspect":
|
||
if !resolved.SkipExecute || resolved.Target != nil || !resolved.ReadOnly {
|
||
return block("execute a malformed dynamic inspection")
|
||
}
|
||
return toolOutcome{}, false
|
||
case "decline":
|
||
return block("decline a capability decision")
|
||
case "call":
|
||
if resolved.Target == nil {
|
||
if a.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
|
||
if _, ok := parseMCPServerCapabilityID(resolved.CapabilityID); ok {
|
||
return toolOutcome{}, false
|
||
}
|
||
}
|
||
return block("execute an unresolved dynamic capability")
|
||
}
|
||
if a.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
|
||
if isMCPLifecycleConnectTarget(resolved.Target) {
|
||
if !plannerMCPConnectAllowed(resolved.Target) {
|
||
return block("start an unauthorized MCP server")
|
||
}
|
||
} else if !mcpServerAuthorized(resolved.Target) {
|
||
return block("execute an MCP capability from an unauthorized server")
|
||
}
|
||
if readOnlyExecutionMCPDestructive(resolved.Target) {
|
||
name := resolved.TargetName
|
||
if name == "" {
|
||
name = resolved.CapabilityID
|
||
}
|
||
return blockDestructiveForExecutor(name)
|
||
}
|
||
return toolOutcome{}, false
|
||
}
|
||
if !resolved.ReadOnly {
|
||
if reasoner, ok := resolved.Target.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
|
||
return block(reasoner.ReadOnlyExecutionBlockReason())
|
||
}
|
||
return block("execute a state-changing dynamic capability")
|
||
}
|
||
if isInstalledMCPTool(resolved.Target) && !mcpServerAuthorized(resolved.Target) {
|
||
return block("execute a dynamic reader from an unauthorized MCP server")
|
||
}
|
||
if readOnlyExecutionMCPDestructive(resolved.Target) {
|
||
return block("execute a destructive MCP capability")
|
||
}
|
||
if h, ok := resolved.Target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(resolved.Target) {
|
||
return block("start or mutate a host capability")
|
||
}
|
||
return toolOutcome{}, false
|
||
default:
|
||
return block("execute an unknown dynamic capability action")
|
||
}
|
||
}
|
||
|
||
func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
|
||
return mcpDestructiveHint(t)
|
||
}
|
||
|
||
func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
|
||
if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
|
||
return false
|
||
}
|
||
if !mcpServerAuthorized(t) {
|
||
return false
|
||
}
|
||
meta, ok := t.(tool.MCPMetadata)
|
||
if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// plannerAllowsMCPTarget reports whether a resolved use_capability target is an
|
||
// MCP tool or lifecycle connect that Planner may consider under
|
||
// PlannerMCPExecution (authorization and destructive checks run separately).
|
||
func plannerAllowsMCPTarget(t tool.Tool, targetName string) bool {
|
||
if t == nil {
|
||
return false
|
||
}
|
||
if isInstalledMCPTool(t) || isMCPLifecycleConnectTarget(t) {
|
||
return true
|
||
}
|
||
return isMCPExecutionTarget(t, targetName)
|
||
}
|
||
|
||
// isMCPLifecycleConnectTarget identifies on-demand MCP connect-and-list targets
|
||
// (mcp_connect__<server>) used by use_capability action=call on mcp-server ids.
|
||
func isMCPLifecycleConnectTarget(t tool.Tool) bool {
|
||
if t == nil {
|
||
return false
|
||
}
|
||
if _, ok := t.(mcpLifecycleConnect); ok {
|
||
return true
|
||
}
|
||
name := strings.TrimSpace(t.Name())
|
||
return strings.HasPrefix(name, "mcp_connect__")
|
||
}
|
||
|
||
// mcpLifecycleConnect is implemented by deferred connect targets so Planner
|
||
// can authorize lifecycle actions without relying on name prefixes alone.
|
||
type mcpLifecycleConnect interface {
|
||
MCPLifecycleConnect() bool
|
||
MCPServerAuthorized() bool
|
||
}
|
||
|
||
func plannerMCPConnectAllowed(t tool.Tool) bool {
|
||
if life, ok := t.(mcpLifecycleConnect); ok {
|
||
return life.MCPServerAuthorized()
|
||
}
|
||
return mcpServerAuthorized(t)
|
||
}
|
||
|
||
func isInstalledMCPTool(t tool.Tool) bool {
|
||
meta, ok := t.(tool.MCPMetadata)
|
||
return ok && strings.TrimSpace(meta.MCPServerName()) != "" && strings.TrimSpace(meta.MCPRawToolName()) != ""
|
||
}
|
||
|
||
func isMCPExecutionTarget(t tool.Tool, name string) bool {
|
||
return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
|
||
}
|
||
|
||
func mcpServerAuthorized(t tool.Tool) bool {
|
||
authority, ok := t.(tool.MCPServerAuthorization)
|
||
return ok && authority.MCPServerAuthorized()
|
||
}
|
||
|
||
func mcpDestructiveHint(t tool.Tool) bool {
|
||
annotations, ok := t.(tool.MCPAnnotations)
|
||
return ok && annotations.MCPDestructiveHint()
|
||
}
|
||
|
||
func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
|
||
return (planmode.Policy{}).Decide(planmode.Call{
|
||
Name: toolName,
|
||
ReadOnly: readOnly,
|
||
Safety: safety,
|
||
Args: args,
|
||
})
|
||
}
|
||
|
||
func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (string, bool) {
|
||
sig, ok := repeatSuccessSignature(call, t)
|
||
if !ok || a.repeatSuccessCounts == nil {
|
||
return "", false
|
||
}
|
||
count := a.repeatSuccessCounts[sig]
|
||
if count < repeatSuccessBreakThreshold {
|
||
return "", false
|
||
}
|
||
return fmt.Sprintf(
|
||
"blocked: [loop guard] %q has already succeeded %d times with the same write-like arguments in this user turn. Re-running it is unlikely to help and may burn tokens or repeat file writes. Change approach: use edit_file or multi_edit for file changes, verify with a read/test command, or explain the blocker in your final answer.",
|
||
call.Name, count), true
|
||
}
|
||
|
||
func (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
|
||
if a.evidence == nil || !anchorBasedEditTool(call.Name) {
|
||
return "", false
|
||
}
|
||
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), true, false)
|
||
if len(rec.Paths) == 0 {
|
||
return "", false
|
||
}
|
||
writeIndex, ok := a.evidence.LatestSuccessfulWriteIndex(rec.Paths)
|
||
if !ok || a.evidence.HasSuccessfulAnchorRefreshReadAfter(rec.Paths, writeIndex) {
|
||
return "", false
|
||
}
|
||
return fmt.Sprintf(
|
||
"blocked: [fresh read required] %q targets %s, which was already modified earlier this turn. Re-read the current file with read_file without offset/limit before another range deletion, or use multi_edit with exact replacements when possible. This prevents stale start/end anchors from selecting an unintended destructive span.",
|
||
call.Name, strings.Join(rec.Paths, ", ")), true
|
||
}
|
||
|
||
func anchorBasedEditTool(name string) bool {
|
||
switch name {
|
||
// edit_file synchronously reads the current file, requires a unique exact
|
||
// or narrowly fuzzy match, and returns the actual applied diff. Let it try
|
||
// optimistically; a stale old_string fails without writing and tells the
|
||
// model to re-read. delete_range remains guarded because two independently
|
||
// resolved anchors can otherwise select an unintended destructive span.
|
||
case "delete_range":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (a *Agent) recordRepeatSuccess(call provider.ToolCall, t tool.Tool) {
|
||
sig, ok := repeatSuccessSignature(call, t)
|
||
if !ok {
|
||
return
|
||
}
|
||
if a.repeatSuccessCounts == nil {
|
||
a.repeatSuccessCounts = make(map[string]int)
|
||
}
|
||
a.repeatSuccessCounts[sig]++
|
||
}
|
||
|
||
func repeatSuccessSignature(call provider.ToolCall, t tool.Tool) (string, bool) {
|
||
if t.ReadOnly() {
|
||
return "", false
|
||
}
|
||
switch call.Name {
|
||
case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit":
|
||
return call.Name + "\x00" + canonicalToolArgs(call.Arguments), true
|
||
case "bash":
|
||
var p struct {
|
||
Command string `json:"command"`
|
||
RunInBackground bool `json:"run_in_background"`
|
||
}
|
||
if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
|
||
return "", false
|
||
}
|
||
if p.RunInBackground || !isShellFileWriteCommand(p.Command) {
|
||
return "", false
|
||
}
|
||
return "bash\x00" + normalizeShellCommand(p.Command), true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func canonicalToolArgs(raw string) string {
|
||
var v any
|
||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||
return strings.TrimSpace(raw)
|
||
}
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return strings.TrimSpace(raw)
|
||
}
|
||
var compact bytes.Buffer
|
||
if err := json.Compact(&compact, b); err != nil {
|
||
return string(b)
|
||
}
|
||
return compact.String()
|
||
}
|
||
|
||
func normalizeShellCommand(command string) string {
|
||
if fields, malformed := shellparse.StaticFields(command); malformed == "" && len(fields) > 0 {
|
||
return strings.Join(fields, " ")
|
||
}
|
||
return strings.Join(strings.Fields(command), " ")
|
||
}
|
||
|
||
func isShellFileWriteCommand(command string) bool {
|
||
lower := strings.ToLower(command)
|
||
switch {
|
||
case shellPythonOpenWrites(lower):
|
||
return true
|
||
case strings.Contains(lower, "set-content") || strings.Contains(lower, "add-content") || strings.Contains(lower, "out-file"):
|
||
return true
|
||
case strings.Contains(lower, "sed -i") || strings.Contains(lower, "perl -pi"):
|
||
return true
|
||
case hasShellWriteRedirect(command):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func shellPythonOpenWrites(lower string) bool {
|
||
if !strings.Contains(lower, "open(") {
|
||
return false
|
||
}
|
||
if strings.Contains(lower, ".write(") {
|
||
return true
|
||
}
|
||
for _, marker := range []string{", 'w", `, "w`, ", 'a", `, "a`, ", 'x", `, "x`, "mode='w", `mode="w`, "mode='a", `mode="a`, "mode='x", `mode="x`} {
|
||
if strings.Contains(lower, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasShellWriteRedirect(command string) bool {
|
||
file, err := shellparse.ParseBash(command)
|
||
if err == nil {
|
||
hasWrite := false
|
||
syntax.Walk(file, func(node syntax.Node) bool {
|
||
redir, ok := node.(*syntax.Redirect)
|
||
if !ok {
|
||
return true
|
||
}
|
||
if bashRedirectWritesFile(command, redir) {
|
||
hasWrite = true
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
return hasWrite
|
||
}
|
||
return hasShellWriteRedirectFallback(command)
|
||
}
|
||
|
||
func bashRedirectWritesFile(source string, redir *syntax.Redirect) bool {
|
||
if redir == nil {
|
||
return false
|
||
}
|
||
switch redir.Op {
|
||
case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob,
|
||
syntax.RdrAll, syntax.RdrAllClob, syntax.AppAll, syntax.AppAllClob,
|
||
syntax.RdrInOut:
|
||
return !redirectWordIsNullSink(source, redir.Word)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func redirectWordIsNullSink(source string, word *syntax.Word) bool {
|
||
if word == nil {
|
||
return false
|
||
}
|
||
if value, ok := shellparse.StaticWord(word); ok {
|
||
if isNullSinkWord(strings.TrimSpace(value)) {
|
||
return true
|
||
}
|
||
}
|
||
value := strings.TrimSpace(redirectWordSource(source, word))
|
||
if isNullSinkWord(value) {
|
||
return true
|
||
}
|
||
if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
|
||
return isNullSinkWord(value[1 : len(value)-1])
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isNullSinkWord(value string) bool {
|
||
if value == "/dev/null" {
|
||
return true
|
||
}
|
||
return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul")
|
||
}
|
||
|
||
func redirectWordSource(source string, word *syntax.Word) string {
|
||
if word == nil || !word.Pos().IsValid() || !word.End().IsValid() {
|
||
return ""
|
||
}
|
||
start := int(word.Pos().Offset())
|
||
end := int(word.End().Offset())
|
||
if start < 0 || end < start || end > len(source) {
|
||
return ""
|
||
}
|
||
return source[start:end]
|
||
}
|
||
|
||
func hasShellWriteRedirectFallback(command string) bool {
|
||
var quote rune
|
||
var prev rune
|
||
for _, r := range command {
|
||
if quote != 0 {
|
||
if r == quote {
|
||
quote = 0
|
||
}
|
||
prev = r
|
||
continue
|
||
}
|
||
if r == '\'' || r == '"' {
|
||
quote = r
|
||
prev = r
|
||
continue
|
||
}
|
||
if r == '>' {
|
||
if prev == '2' {
|
||
prev = r
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
prev = r
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isBackgroundTaskCall reports whether a `task` call set run_in_background, so a
|
||
// fire-and-return dispatch isn't mistaken for a sub-agent that has stopped.
|
||
func isBackgroundTaskCall(args string) bool {
|
||
var p struct {
|
||
RunInBackground bool `json:"run_in_background"`
|
||
}
|
||
_ = json.Unmarshal([]byte(args), &p)
|
||
return p.RunInBackground
|
||
}
|
||
|
||
// toolReadOnly reports a tool's ReadOnly classification by name (false for an
|
||
// unknown tool), for stamping early ToolDispatch events.
|
||
func (a *Agent) toolReadOnly(name string) bool {
|
||
t, _, ambiguous := a.tools.ResolveCall(name)
|
||
return t != nil && len(ambiguous) == 0 && t.ReadOnly()
|
||
}
|
||
|
||
// firstLine returns s up to its first newline — a one-line failure summary for
|
||
// the display Err, while the full error stays in the model-facing output.
|
||
func firstLine(s string) string {
|
||
if before, _, ok := strings.Cut(s, "\n"); ok {
|
||
return before
|
||
}
|
||
return s
|
||
}
|
||
|
||
// truncateToolOutput head+tails s when it exceeds maxToolOutputBytes, slicing
|
||
// on rune boundaries so we never split a multibyte glyph. Returns the possibly
|
||
// trimmed body plus a one-line user-facing notice when truncation happened
|
||
// (empty when it didn't, without the "· " display prefix).
|
||
func truncateToolOutput(s string) (string, string) {
|
||
if len(s) <= maxToolOutputBytes {
|
||
return s, ""
|
||
}
|
||
keep := maxToolOutputBytes / 2
|
||
head := snapToRuneBoundary(s, 0, keep)
|
||
tail := snapToRuneBoundary(s, len(s)-keep, len(s))
|
||
omitted := len(s) - len(head) - len(tail)
|
||
notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s))
|
||
body := head + fmt.Sprintf("\n\n…[truncated %d of %d bytes — rerun with narrower args to see the middle]…\n\n", omitted, len(s)) + tail
|
||
return body, notice
|
||
}
|
||
|
||
// snapToRuneBoundary returns s[lo:hi] with the bounds nudged outward until
|
||
// both land on rune-start positions.
|
||
func snapToRuneBoundary(s string, lo, hi int) string {
|
||
for lo > 0 && !utf8.RuneStart(s[lo]) {
|
||
lo--
|
||
}
|
||
for hi < len(s) && !utf8.RuneStart(s[hi]) {
|
||
hi++
|
||
}
|
||
return s[lo:hi]
|
||
}
|
||
|
||
// finishReasonMessage maps an abnormal finish_reason to a one-line warning,
|
||
// returning ok=false for the normal terminations ("stop", "tool_calls") and a
|
||
// nil usage. The sink renders the message; the "! " prefix is presentation.
|
||
func finishReasonMessage(u *provider.Usage) (string, bool) {
|
||
if u == nil {
|
||
return "", false
|
||
}
|
||
switch u.FinishReason {
|
||
case "length":
|
||
return "response truncated: hit max output tokens", true
|
||
case finishReasonClientReasoningLimit:
|
||
return "response stopped: hit the client reasoning safety limit", true
|
||
case "content_filter":
|
||
return "response blocked by content filter", true
|
||
case "repetition_truncation":
|
||
return "response truncated: model repetition detected", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|