Files

1874 lines
71 KiB
Go

// Package agent wires a Provider, a tool Registry, and a Session into the
// harness loop that drives a coding task to completion.
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"reasonix/internal/runtime/langpref"
"reasonix/internal/runtime/usecap"
"reasonix/internal/state/sessionstore"
"slices"
"strings"
"sync/atomic"
"time"
"unicode/utf8"
"reasonix/internal/base/diff"
"reasonix/internal/base/nilutil"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/agentpreset"
"reasonix/internal/contract/event"
"reasonix/internal/contract/planmode"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/ext/extension/dispatch"
"reasonix/internal/runtime/capability"
"reasonix/internal/runtime/plancontract"
"reasonix/internal/runtime/taskpolicy"
"reasonix/internal/safety/evidence"
"reasonix/internal/safety/sandbox"
"reasonix/internal/state/checkpoint"
"reasonix/internal/state/instruction"
"reasonix/internal/state/memory"
"reasonix/internal/tools/jobs"
)
// 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
// 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 an unresolved result and never an
// answer. 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/tools/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: event.BindUsageScope(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
}
// withAgentContext establishes the agent-owned workflow capabilities for a
// model round and for tool availability checks. Missing capabilities shadow
// inherited values so child agents cannot reach parent Goal, Jobs, or memory
// state accidentally.
func (a *Agent) withAgentContext(ctx context.Context) context.Context {
if a == nil {
return ctx
}
if a.svc.jobs != nil {
ctx = jobs.WithManager(ctx, a.svc.jobs)
} else {
ctx = jobs.WithoutManager(ctx)
}
if a.svc.memQueue != nil {
ctx = memory.WithQueue(ctx, a.svc.memQueue)
} else {
ctx = memory.WithoutQueue(ctx)
}
return planmode.WithActive(ctx, a.PlanningPhase())
}
// 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 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
}
// UnlimitedSubagentDepth is the depth a user who set max_subagent_depth to 0
// asked for: no layer count stops delegation, only cost does.
const UnlimitedSubagentDepth = 1 << 20
// SubagentDepthFromConfig reads the user's max_subagent_depth: 0 removes the
// limit, a negative value reads as the single-layer boundary.
func SubagentDepthFromConfig(depth int) int {
if depth == 0 {
return UnlimitedSubagentDepth
}
return NormalizeMaxSubagentDepth(depth)
}
// ProviderRetriesFromConfig reads the user's max_provider_retries: 0 removes
// the limit, a negative value keeps the provider default.
func ProviderRetriesFromConfig(retries int) int {
switch {
case retries == 0:
return provider.RetriesUntilCancelled
case retries < 0:
return 0
}
return retries
}
// 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
// SubagentStart/Stop bracket a foreground `task`. PreCompact returns its hooks'
// stdout as summary guidance for the next compaction; "" when none contributes.
SubagentStart(ctx context.Context, callID string, args json.RawMessage)
SubagentStop(ctx context.Context, callID, last string, err error)
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 {
agentConfig
// svc are the collaborators this agent talks to; see services.go.
svc agentServices
// stops lets one running call be ended without ending the turn.
stops callStops
// role is what this agent is; see agent_role.go.
role agentRole
// eventSource names this agent as the producer of its own frames.
eventSource string
// sess is the state one conversation owns; SetSession restarts it. See
// sessionstate.go.
sess sessionRuntime
responseLanguage atomic.Value // string: auto|zh|en
progressWatch atomic.Pointer[ProgressWatch]
reasoningLanguage atomic.Value // string: auto|zh|en
// planRuntime is the plan lifecycle: which phase, under which authority
// epoch. Replaceable, so a controller can hand several agents the one
// lifecycle they share. Never a permission or sandbox boundary.
planRuntime atomic.Pointer[planmode.Runtime]
// mutationDependencyBarrier is how much of the rest of a provider tool batch
// an earlier failure takes with it (see barrierLevel). executeOne re-checks
// it after proxy resolution so use_capability cannot bypass it by
// advertising schema-level ReadOnly()==true. Parallel read-only segments
// never set it. Cleared at the start of each executeBatch.
mutationDependencyBarrier atomic.Int32
// recovery is who this agent is to the shared gate above.
recovery recoveryIdentity
// writeWorkspaceRoot is the workspace used to normalize parent write
// reservations when writeScheduler is set.
// steer holds mid-turn guidance admitted while the agent is running.
// Entries keep a durable inbox item ID plus a loader so full bodies are not
// retained in the agent heap beyond need. Cache miss for the next API call
// is unavoidable but limited to one call — the prefix stays stable otherwise.
steer steerInbox
// task is the state shared by every Run continuing one delivery scope: the
// receipt ledger complete_step validates citations against, the spend that
// outlives a single Run, and the guards keyed to the task rather than the
// turn. See taskstate.go.
task taskRuntime
planContract *plancontract.Plan // approved plan this turn executes, if any
// 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
// projectSensitivePaths are the globs the project declared as needing the
// hardest review. Empty means undeclared, and the host says so rather than
// guessing sensitivity from how a path is spelled.
projectSensitivePaths []string
// deliveryProfile enables the runtime-enforced delivery contract. The stable
// profile prompt explains intent; this is host state and never enters the
// provider-cached prefix. The scope ID and checkpoint it works against live
// in task; the per-turn expectations live in turn.
// When agentPreset is set, deliveryProfile is derived for baseline Delivery
// and may be elevated per-turn by TaskPolicy (e.g. Light high-risk).
deliveryProfile bool
// agentPreset is the session role setting. Atomic so SetAgentPreset can
// update subsequent turns without rebuilding the agent.
agentPreset atomic.Value // string light|balanced|delivery
// turn is the state of the Run currently executing; beginRunTurn replaces
// it wholesale. See turnruntime.go.
turn turnRuntime
// ablation names the subsystems a benchmark arm switched off. The zero value
// is the control arm.
ablation ablation.Set
// pending is what an external caller arms before the next Run; see
// turnruntime.go.
pending pendingTurn
// 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
// capabilityGate is the turn's gate memory across final-answer retries.
capabilityGate capabilityGateState
// subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
// caps delegation; when reached, recursive agent/skill tools are excluded.
// windowProbe holds what a provider that declares no context window has
// revealed about it by rejecting or accepting requests of a known size.
windowProbe contextWindowProbe
// Context management keeps the canonical transcript immutable and installs
// at most one provider-visible checkpoint each time compactRatio is crossed.
strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies
// 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
}
// SetPlanMode enters or leaves the plan-first workflow. 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.plan().SetActive(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.svc.setTools(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(langpref.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(langpref.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.svc.setGate(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.svc.setExtensions(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.svc.recoveryGate = g
}
// SetRecoveryIdentity sets the agent/task labels used on recovery cards.
func (a *Agent) SetRecoveryIdentity(agentID, taskID string) {
if a == nil {
return
}
a.recovery.agentID = strings.TrimSpace(agentID)
a.recovery.taskID = strings.TrimSpace(taskID)
}
// RecoveryGate returns the attached Auto Guard (may be nil).
func (a *Agent) RecoveryGate() RecoveryGate {
if a == nil {
return nil
}
return a.svc.recoveryGate
}
// 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.svc.sandboxEscape = 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.svc.configWrite = g
}
// SetPaidSearchApprover installs the path that asks the user before web_search
// uses a backend the vendor charges for. Without it no paid backend is called.
func (a *Agent) SetPaidSearchApprover(g tool.PaidSearchApprover) {
if nilutil.IsNil(g) {
g = nil
}
a.svc.paidSearch = 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 = langpref.WithResponseLanguage(input, responseLang)
lang := "auto"
if v := a.reasoningLanguage.Load(); v != nil {
if s, ok := v.(string); ok {
lang = s
}
}
input = langpref.WithReasoningLanguage(input, lang)
input = WithWorkspace(input, a.writeWorkspaceRoot, a.workspaceVCS, a.extraRoots)
// The frozen role setting rides the per-turn <execution-policy>, not a marker.
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.svc.setAsker(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.svc.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.svc.preEdit = 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.svc.mutationObserver = obs
if a.svc.tools == nil || obs == nil {
return
}
if t, ok := a.svc.tools.Get("task"); ok {
if observed, ok := t.(mutationObserverSetter); ok {
observed.SetMutationObserver(obs)
}
}
}
// MutationObserver returns the installed observer (may be nil).
func (a *Agent) MutationObserver() *checkpoint.MutationObserver {
if a == nil {
return nil
}
return a.svc.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() *sessionstore.Session {
a.sess.mu.Lock()
defer a.sess.mu.Unlock()
return a.sess.conversation
}
// 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 *sessionstore.Session) {
a.sess.reset(s)
a.replayTouchedPaths(s)
// The replaced conversation's task is over, but the ledger and the bill
// answer to beginRunTurn's scope check rather than to this seam.
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; ContextManager.Prepare owns cache-breaking
// maintenance decisions.
func (a *Agent) LastUsage() *provider.Usage { return a.sess.output.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.sess.cacheHit.Load()), int(a.sess.cacheMiss.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.window().effectiveContextWindow() }
// SetSink replaces the agent's event sink. Controllers use this to wrap the
// sink after construction (e.g. durable inbox observation) without rebuilding
// the agent.
func (a *Agent) SetSink(sink event.Sink) {
if a == nil {
return
}
if nilutil.IsNil(sink) {
sink = event.Discard
}
a.svc.sink = withProducer(sink, a.eventSource)
}
// 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 requested projection compaction (canonical transcript
// untouched). Asking waives the automatic threshold and how much was added since
// the last checkpoint, but a context nothing was added to is still declined
// rather than summarized a second time. Only IgnoreEconomics waives that.
func (a *Agent) CompactNow(ctx context.Context, req CompactRequest) (CompactVerdict, error) {
prepared, err := a.window().contextManager().Prepare(ctx, ContextPreparePolicy{
Trigger: CompactionTriggerManual,
Instructions: req.Instructions,
IgnoreThreshold: true,
IgnoreEconomics: req.IgnoreEconomics,
})
return prepared.Maintenance, err
}
// 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 *sessionstore.Session, opts Options, sink event.Sink) *Agent {
if opts.CompactRatio <= 0 {
opts.CompactRatio = defaultCompactRatio
}
if nilutil.IsNil(sink) {
sink = event.Discard
}
if opts.EventSource == "" {
opts.EventSource = event.UsageSourceExecutor
}
sink = withProducer(sink, opts.EventSource)
gate := opts.Gate
if nilutil.IsNil(gate) {
gate = 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
}
perseverationGuard, perseverationMaxRetries := resolvePerseverationGuard(opts.MaxPerseverationRetries)
a := &Agent{
svc: newAgentServices(prov, tools, sink, gate,
sandboxEscapeApprover, configWriteApprover, hooks, opts),
agentConfig: agentConfig{
maxSteps: opts.MaxSteps,
maxStepsKey: maxStepsKey,
reasoningByteLimit: reasoningByteLimit,
perseverationGuard: perseverationGuard,
perseverationMaxRetries: perseverationMaxRetries,
maxOutputTokens: opts.MaxOutputTokens,
temperature: opts.Temperature,
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
modelRef: strings.TrimSpace(opts.ModelRef),
workspaceID: strings.TrimSpace(opts.WorkspaceID),
classifierTaskText: opts.ClassifierTaskText,
writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
observeRoot: opts.observeRoot(),
extraRoots: slices.Clone(opts.ExtraWorkspaceRoots),
workspaceScanLimit: opts.WorkspaceScanLimit,
renderRoot: strings.TrimSpace(opts.RenderRoot),
workspaceVCS: strings.TrimSpace(opts.WorkspaceVCS),
workspaceUnversioned: opts.WorkspaceUnversioned,
subagentDepth: subagentDepth,
maxSubagentDepth: maxSubagentDepth,
providerRetries: opts.ProviderRetries,
contextWindow: opts.ContextWindow,
compactRatio: opts.CompactRatio,
budgets: opts.CompactionBudgets,
archiveDir: opts.ArchiveDir,
},
sess: sessionRuntime{
conversation: session,
path: strings.TrimSpace(opts.SessionPath),
win: windowState{cacheState: CacheStateUnknown},
},
task: newTaskRuntime(opts.TaskBudget),
role: newAgentRole(opts),
eventSource: opts.EventSource,
recovery: recoveryIdentity{
agentID: strings.TrimSpace(opts.RecoveryAgentID),
taskID: strings.TrimSpace(opts.RecoveryTaskID),
},
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
projectSensitivePaths: append([]string(nil), opts.ProjectSensitivePaths...),
deliveryProfile: opts.DeliveryProfile || agentpreset.Normalize(opts.AgentPreset) == agentpreset.Delivery,
ablation: opts.Ablation,
capabilityLedger: opts.CapabilityLedger,
capabilityAudit: opts.CapabilityAudit,
strictAlternatingRoles: opts.StrictAlternatingRoles,
}
a.sess.output.outputBudget = outputBudgetOf(prov)
if a.sess.path != "" {
a.LoadProjectionSidecar(a.sess.path)
}
preset := strings.TrimSpace(opts.AgentPreset)
if preset == "" && opts.DeliveryProfile {
preset = string(agentpreset.Delivery)
}
a.SetAgentPreset(preset)
a.SetResponseLanguage(opts.ResponseLanguage)
a.SetReasoningLanguage(opts.ReasoningLanguage)
a.SetProgressWatch(opts.ProgressWatch)
a.SetRequestEffort(opts.RequestEffort)
return a
}
// SetAgentPreset updates the role setting used by subsequent turns. It does not
// rebuild providers, tools, or history. The in-flight turn keeps its frozen
// TaskPolicy.
func (a *Agent) SetAgentPreset(preset string) {
if a == nil {
return
}
p := agentpreset.Normalize(preset)
a.agentPreset.Store(string(p))
// Keep baseline deliveryProfile aligned with Delivery role setting so
// legacy gates that still read the bool stay coherent until fully migrated.
switch p {
case agentpreset.Delivery:
a.deliveryProfile = true
case agentpreset.Balanced:
// A turn may still elevate; the baseline stays non-delivery.
a.deliveryProfile = false
}
}
// AgentPreset returns the current session role setting (never empty).
func (a *Agent) AgentPreset() string {
if a == nil {
return string(agentpreset.Balanced)
}
if v := a.agentPreset.Load(); v != nil {
if s, ok := v.(string); ok && s != "" {
return string(agentpreset.Normalize(s))
}
}
if a.deliveryProfile {
return string(agentpreset.Delivery)
}
return string(agentpreset.Balanced)
}
// TurnPolicy returns the frozen TaskPolicy for the active turn, if any.
func (a *Agent) TurnPolicy() (taskpolicy.TaskPolicy, bool) {
if a == nil || !a.turn.policySet {
return taskpolicy.TaskPolicy{}, false
}
return a.turn.policy, true
}
func usageSourceOrDefault(source, fallback string) string {
source = strings.TrimSpace(source)
if source != "" {
return source
}
return fallback
}
// 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, declared []string) (release func(), err error) {
noop := func() {}
if a == nil || a.svc.writeScheduler == nil || a.subagentDepth > 0 || readOnly || runTool == nil {
return noop, nil
}
if len(declared) > 0 {
return a.reserveDeclaredWrite(declared)
}
name := runTool.Name()
if !parentWriteGuardTarget(name) {
return noop, nil
}
claim, err := a.parentToolWriteReservation(runTool, args)
if err != nil {
return noop, err
}
return a.svc.writeScheduler.ReserveWrite(claim)
}
// Run appends the user input and drives the tool loop until the model returns a
// final answer, the context is cancelled, or the provider errors. maxSteps <= 0
// leaves the loop unbounded here: bounding it is the host's call, as a TaskBudget
// in hours or tokens rather than a round count, and every such budget ships off.
// Turn policy lives in beginRunTurn / runToolLoop / handleFinalResponse.
func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
ctx = a.compositionContext(ctx)
ctx, report := takeRunTimeSink(ctx)
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.recovery.runSeq.Add(1)
// All role settings participate in the workspace lease for the run; the
// exclusive write lock is still acquired lazily on the first real writer.
if a.svc.workspaceLease != nil {
a.svc.workspaceLease.BeginRun()
defer a.svc.workspaceLease.EndRun()
}
turnStartedAt := time.Now()
workDurationMs := func() int64 {
if elapsed := time.Since(turnStartedAt).Milliseconds(); elapsed > 0 {
return elapsed
}
return 1
}
defer a.flushSteerQueue()
a.steer.open()
// 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.task.ledger == nil || a.svc.jobs == nil {
return
}
for _, lease := range a.task.ledger.BackgroundLeases() {
a.svc.jobs.CommitEvidenceForSession(lease.Session, lease.JobID)
}
}()
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)
ctx = a.bindRootTokenBudget(ctx, state)
state.runMaxSteps = runMaxSteps
state.runMaxStepsKey = runMaxStepsKey
state.runLimitHostOwned = runLimitHostOwned
state.workDurationMs = workDurationMs
err := a.runToolLoop(ctx, state)
report(state.answeredMs, err)
return err
}
// 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.task.checkpoint
}
// 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.task.checkpoint = checkpoint
a.task.scopeID = checkpoint.ScopeID
}
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
a.sess.todoMu.Lock()
a.sess.todoState = evidence.NormalizeSerialTodos(todos)
a.sess.todoMu.Unlock()
}
func (a *Agent) hasActiveCanonicalTodo() bool {
a.sess.todoMu.Lock()
defer a.sess.todoMu.Unlock()
for _, todo := range a.sess.todoState {
if canonicalTodoStatus(todo.Status) == "in_progress" {
return true
}
}
return false
}
// 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, Issuer: event.IssuedByHost}
a.svc.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
t.Output = "task list advanced by complete_step"
a.svc.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.ExecutionArguments()), true, evidence.ToolFacts{ReadOnly: 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.ExecutionArguments()), true, evidence.ToolFacts{ReadOnly: 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")
}
// shouldNudgeExecutorHandoff reports whether an executor that finished a
// planner handoff without touching a tool owes another attempt. The caller has
// already established the hard part — no tool ran — so the only question left
// is whether this plan called for one, which the plan contract states outright.
func (a *Agent) shouldNudgeExecutorHandoff() bool {
plan := a.PlanContract()
if plan == nil {
// No contract, no basis: nudging would interrupt an answer that may be
// exactly right, on nothing but a guess about what the plan wanted.
return false
}
return planContractExpectsAction(*plan)
}
// planContractExpectsAction reports whether carrying out the plan requires
// touching the workspace: a step that names files cannot be
// delivered as prose alone.
func planContractExpectsAction(plan plancontract.Plan) bool {
for _, step := range plan.Steps {
if len(step.VerifiedFiles) > 0 || len(step.CandidateFiles) > 0 {
return true
}
}
return false
}
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) != ""
}
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."
}
// 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 = withHeaderRetries(ctx, sink, a.providerRetries)
// 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
}
// Host stream cancels on generation drain (OpenAI/Anthropic HTTP reads).
defer TrackPublishedHostStream(ctx, cancel)()
thought := thoughtClock{begin: time.Now()}
ch, err := a.streamProviderRequest(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.svc.hooks != nil && a.svc.hooks.HasPostLLMCall()
var text, reasoning strings.Builder
var reasoningBytes int // streamed, including what the retention limit dropped
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
perseveration, perseverationDetected := newPerseverationGuards(), false
// collect packages the accumulated stream state; stored is the round-tripped reasoning.
collect := func(stored string, err error) streamedTurn {
return streamedTurn{
text: text.String(), reasoning: stored, signature: signature, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs(),
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
calls: calls, responsesItems: responsesItems, usage: usage,
partialToolStarted: partialToolStarted, partialCalls: partialCalls,
maxArgChars: maxArgChars, err: err, perseverationDetected: perseverationDetected,
}
}
finishReasoning := func() (stored, display string) {
original := reasoning.String()
display = original
if transformReasoning && original != "" {
display = a.svc.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.svc.prov) || (len(calls) > 0 && provider.RequiresToolCallReasoning(a.svc.prov)) {
stored = original
}
return stored, display
}
for {
var chunk provider.Chunk
select {
case <-ctx.Done():
stored, _ := finishReasoning()
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "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(), reasoningBytes, "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
}
emitAssistantMessage(sink, finalText, display, thought.ms())
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, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs(),
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
calls: calls, responsesItems: responsesItems, usage: usage,
partialCalls: partialCalls, maxArgChars: maxArgChars, perseverationDetected: perseverationDetected,
}
}
chunk = c
}
thought.observe(chunk, time.Now())
switch chunk.Type {
case provider.ChunkReasoning:
retainReasoning(&reasoning, chunk.Text, reasoningBytes, a.reasoningByteLimit)
reasoningBytes += len(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})
}
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, Issuer: event.IssuedByModel,
}})
}
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, Issuer: event.IssuedByModel,
}})
}
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:
responsesItems = appendProviderItem(responsesItems, chunk.ResponsesItem)
case provider.ChunkProviderTool:
a.absorbProviderRunCall(&text, sink, chunk, attemptID)
case provider.ChunkUsage:
usage = chunk.Usage
a.storeLatestRequestUsage(chunk.Usage)
a.sess.cacheHit.Add(int64(chunk.Usage.CacheHitTokens))
a.sess.cacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
case provider.ChunkError:
if provider.IsStreamInterrupted(chunk.Err) {
stored, _ := finishReasoning()
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "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(), reasoningBytes, "interrupted")
}
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
return collect(stored, chunk.Err)
}
if cut := a.perseverationStep(perseveration, chunk, sink, &perseverationDetected, perseverationAbort{ctx: ctx, sink: sink,
finishReasoning: finishReasoning, collect: collect, reasoningBytes: reasoningBytes, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs()}); cut != nil {
return *cut
}
}
}
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, cause provider.StreamFailureCause) {
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.sess.conversation.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,
StreamFailure: cause,
InterruptedTools: interrupted,
DroppedPartialText: strings.TrimSpace(text) != "",
DroppedPartialReasoning: strings.TrimSpace(reasoning) != "",
},
})
}
func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
return CaptureShape(a.systemPrompt(), schemas, a.sess.conversation.RewriteVersion())
}
func (a *Agent) systemPrompt() string {
var b strings.Builder
for _, m := range a.sess.conversation.Messages {
if m.Role != provider.RoleSystem {
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(m.Content)
}
return b.String()
}
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, Subject: in.Subject, JobID: in.JobID,
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
}
func (a *Agent) emitFullToolDispatch(ctx context.Context, c provider.ToolCall, refreshed bool) {
t, _, ambiguous := a.svc.tools.ResolveCall(c.Name)
ok := t != nil && len(ambiguous) == 0
ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.ExecutionArguments(), ArgumentNormalization: c.ArgumentNormalization, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed, Issuer: event.IssuedByModel}
ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
if ok {
ev.Profile = delegationProfile(t, json.RawMessage(c.ExecutionArguments()))
}
a.svc.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, profile *event.Profile) {
if c.ResolvedReadOnly == nil {
return
}
if c.ResolvedName != "" && c.ResolvedName != c.Name {
usecap.EmitProxyAudit(a.svc.sink, tool.ResolvedCall{
DisplayName: c.Name,
TargetName: c.ResolvedName,
CapabilityID: c.CapabilityID,
})
}
a.emitRefreshedDispatch(c, profile)
}
// 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, string) {
if a == nil || toolName != "todo_write" || a.PlanningPhase() {
return false, "", "", ""
}
before := a.CanonicalTodoState()
if len(before) == 0 || len(evidence.IncompleteTodos(before)) == 0 {
return false, "", "", ""
}
after := evidence.ReceiptFromToolCall("todo_write", args, true, evidence.ToolFacts{ReadOnly: 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), planTransitionDiff(before, 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.role.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.role.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
if !tool.IsMCPServerAuthorized(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) && !tool.IsMCPServerAuthorized(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
}
if usecap.IsCatalogReadAction(resolved.ProxyAction) {
if !isHostResolvedCatalogRead(resolved) {
return block("execute a malformed dynamic inspection")
}
return toolOutcome{}, false
}
switch resolved.ProxyAction {
case usecap.ActionDecline:
return block("decline a capability decision")
case usecap.ActionCall:
if resolved.Target == nil {
if a.role.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
if _, ok := usecap.ParseMCPServerCapabilityID(resolved.CapabilityID); ok {
return toolOutcome{}, false
}
}
return block("execute an unresolved dynamic capability")
}
if a.role.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
if isMCPLifecycleConnectTarget(resolved.Target) {
if !plannerMCPConnectAllowed(resolved.Target) {
return block("start an unauthorized MCP server")
}
} else if !tool.IsMCPServerAuthorized(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) && !tool.IsMCPServerAuthorized(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(fmt.Sprintf("execute dynamic capability action %q; allowed: %s, %s", resolved.ProxyAction, strings.Join(usecap.CatalogReadActions, ", "), usecap.ActionCall+" on a read-only target"))
}
}
// isHostResolvedCatalogRead is the structural shape of a catalog read: answered
// by the host, no target, no deferred state transition (decline has a Commit).
func isHostResolvedCatalogRead(rc *tool.ResolvedCall) bool {
return rc.SkipExecute && rc.Target == nil && rc.ReadOnly && rc.Commit == nil
}
func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
return tool.HasMCPDestructiveHint(t)
}
func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
return false
}
if !tool.IsMCPServerAuthorized(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 tool.IsMCPServerAuthorized(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 (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
if a.task.ledger == nil || !anchorBasedEditTool(call.Name) {
return "", false
}
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.ExecutionArguments()), true, a.toolFactsFor(call.Name))
if len(rec.Paths) == 0 {
return "", false
}
writeIndex, ok := a.task.ledger.LatestSuccessfulWriteIndex(rec.Paths)
if !ok || a.task.ledger.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 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.svc.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 is the first-visible hard cap for a tool result. Under-cap
// bodies are returned byte-identical. Over-cap bodies keep a tool-aware head and
// tail under MaxToolOutputBytes; the full original is stored separately as
// RawContent by the session writer. The bounded form is stable for the message
// lifetime and is never re-truncated by later maintenance.
func truncateToolOutput(s string) (string, string) {
return truncateToolOutputFor(s, "", "", MaxToolOutputBytes, defaultSideEffectingSnip)
}
// truncateToolOutputFor is the first-visible limiter. The geometry comes from
// the caller, which reads it off the tool's own SnipHint contract; toolName and
// toolCallID only populate the marker so the model can re-fetch.
func truncateToolOutputFor(s, toolName, toolCallID string, cap int, strategy snipStrategy) (string, string) {
if cap <= 0 {
cap = MaxToolOutputBytes
}
if len(s) <= cap {
return s, ""
}
headKeep := strategy.headChars
tailKeep := strategy.tailChars
if headKeep+tailKeep > cap-512 {
headKeep = cap * 2 / 3
tailKeep = cap - headKeep - 512
}
if headKeep < 1024 {
headKeep = cap / 2
tailKeep = cap / 2
}
head := snapToRuneBoundary(s, 0, headKeep)
tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s))
omitted := len(s) - len(head) - len(tail)
namePart := toolName
if namePart == "" {
namePart = "tool"
}
idPart := toolCallID
if idPart == "" {
idPart = "-"
}
notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s))
marker := fmt.Sprintf(
"\n\n…[truncated tool=%s call_id=%s original_bytes=%d kept_bytes=%d — full original retained in canonical transcript; re-read or retry with narrower args]…\n\n",
namePart, idPart, len(s), len(head)+len(tail),
)
body := head + marker + tail
if len(body) > cap {
overflow := len(body) - cap
trimHead := overflow / 2
trimTail := overflow - trimHead
if trimHead < len(head) {
head = snapToRuneBoundary(head, 0, len(head)-trimHead)
}
if trimTail < len(tail) {
tail = snapToRuneBoundary(tail, trimTail, len(tail))
}
body = head + marker + 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 "content_filter":
return "response blocked by content filter", true
case "repetition_truncation":
return "response truncated: model repetition detected", true
default:
return "", false
}
}