3413 lines
129 KiB
Go
3413 lines
129 KiB
Go
package agent
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"mvdan.cc/sh/v3/syntax"
|
||
|
||
"reasonix/internal/capability"
|
||
"reasonix/internal/diff"
|
||
"reasonix/internal/event"
|
||
"reasonix/internal/evidence"
|
||
"reasonix/internal/instruction"
|
||
"reasonix/internal/jobs"
|
||
"reasonix/internal/memory"
|
||
"reasonix/internal/memorycompiler"
|
||
"reasonix/internal/nilutil"
|
||
"reasonix/internal/planmode"
|
||
"reasonix/internal/provider"
|
||
"reasonix/internal/sandbox"
|
||
"reasonix/internal/secrets"
|
||
"reasonix/internal/shellparse"
|
||
"reasonix/internal/tool"
|
||
)
|
||
|
||
// 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 maxFinalReadinessBlocks = 3
|
||
|
||
// maxFinalReadinessBlocksWithProgress is the hard cap on readiness retries when
|
||
// the model keeps producing new host-observable receipts between blocks. A
|
||
// converging turn (edit → verify → review still catching up to the latest
|
||
// mutation) deserves more nudges than a stuck one; a turn that stalls with no
|
||
// new receipts still fails at maxFinalReadinessBlocks.
|
||
const maxFinalReadinessBlocksWithProgress = 6
|
||
const maxEmptyFinalBlocks = 3
|
||
const maxStreamRecoveries = 3
|
||
const maxExecutorHandoffNudges = 1
|
||
const memoryCompilerInjectionMax = 5
|
||
const memoryCompilerInjectionCooldown = 30 * time.Second
|
||
|
||
// 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.
|
||
func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
|
||
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 under the
|
||
// agent's read-only planning gate. Tools that are themselves ReadOnly may use
|
||
// this to avoid enabling follow-up writer-only surfaces during planning.
|
||
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 the plan-mode gate). 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)
|
||
}
|
||
|
||
const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
|
||
|
||
// PlanModeReadOnlyTrustRequest describes a read-only claim that plan mode will
|
||
// not trust without a user decision. For MCP, ServerName and RawToolName are the
|
||
// identifiers persisted in config. For bash, Command is the concrete attempted
|
||
// command and Prefix is the command prefix to trust for planning.
|
||
type PlanModeReadOnlyTrustRequest struct {
|
||
ToolName string
|
||
ServerName string
|
||
RawToolName string
|
||
Command string
|
||
Prefix string
|
||
Args json.RawMessage
|
||
}
|
||
|
||
// PlanModeReadOnlyTrustGate optionally confirms MCP read-only hints and
|
||
// user-approved bash read-only command prefixes at execution time. It is
|
||
// separate from Gate because the plan-mode check runs before ordinary permission
|
||
// policy.
|
||
type PlanModeReadOnlyTrustGate interface {
|
||
CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error)
|
||
}
|
||
|
||
const (
|
||
MemoryCompilerVerbosityObserve = "observe"
|
||
MemoryCompilerVerbosityCompact = "compact"
|
||
)
|
||
|
||
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)
|
||
// 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
|
||
// 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
|
||
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 dedupes the missing tool-call reasoning
|
||
// notice: when an endpoint stops emitting reasoning it tends to do so for
|
||
// every following round, so the first notice carries the signal and
|
||
// per-round repeats only flood the transcript. Loop-owned; reset by
|
||
// SetSession so a swapped-in conversation warns anew.
|
||
warnedMissingToolCallReasoning bool
|
||
|
||
// planMode, when true, refuses any tool call whose ReadOnly() is false.
|
||
// The system prompt and tool list never change with the toggle so the
|
||
// prompt-cache prefix stays valid; the gating happens at execute time
|
||
// and the model sees a "blocked" result it can adapt to. Toggled from
|
||
// the outside via SetPlanMode.
|
||
planMode atomic.Bool
|
||
|
||
// gate, when non-nil, is the per-call permission gate consulted after the
|
||
// plan-mode check. nil disables gating entirely.
|
||
gate Gate
|
||
|
||
// planModeReadOnlyTrust, when non-nil, can ask the user to trust an MCP
|
||
// server's readOnlyHint for plan-mode execution without changing tool schemas.
|
||
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.
|
||
onPreEdit func(diff.Change)
|
||
|
||
// 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
|
||
|
||
// 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. deliveryCriteriaEstablished resets per user turn
|
||
// but may inherit an unfinished canonical task list on continuation.
|
||
deliveryProfile bool
|
||
deliveryCriteriaEstablished bool
|
||
deliveryTaskExpected bool
|
||
deliveryMutationExpected bool
|
||
|
||
// 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
|
||
|
||
// 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
|
||
|
||
// memoryCompiler, when non-nil, records execution traces and may compile the
|
||
// user turn into a compact execution contract. It never mutates the stable
|
||
// system prompt or tool schema.
|
||
memoryCompilerMu sync.RWMutex
|
||
memoryCompiler *memorycompiler.Runtime
|
||
// observe is the default: Memory v5 writes traces without adding a
|
||
// provider-visible execution contract. compact preserves the old injection.
|
||
memoryCompilerVerbosity string
|
||
compilerTurn *memorycompiler.Turn
|
||
// lastCompilerOutcome is the previous finished turn's persisted outcome.
|
||
// The immediately following user message may retroactively downgrade it
|
||
// when it reports the result wrong. Every non-synthetic turn start
|
||
// consumes it (one-shot) — even while the runtime is nil — so a ref can
|
||
// never survive intervening turns and be replayed after Memory v5 is
|
||
// re-enabled. Session switches clear it. Guarded by memoryCompilerMu.
|
||
lastCompilerOutcome *memorycompiler.OutcomeRef
|
||
|
||
// compilerInjectionMu bounds how often Memory v5 may replace a visible user
|
||
// turn with an execution contract. The runtime can still observe throttled
|
||
// turns for trace writeback, but prompt injection and UI citations stay
|
||
// limited so the compiler does not dominate every conversation turn.
|
||
compilerInjectionMu sync.Mutex
|
||
lastCompilerInjectedAt time.Time
|
||
compilerInjectionCount int
|
||
|
||
// classifier 用于判断用户输入是任务还是聊天,决定是否启动 Memory v5
|
||
classifier TaskClassifier
|
||
|
||
// planModeAllowedTools declares extra custom tools that the centralized
|
||
// plan-mode policy may treat as read-only. Known blocked tools still lose.
|
||
// Populated from Options.PlanModeAllowedTools during construction.
|
||
planModeAllowedTools []string
|
||
planModeReadOnlyCommands []string
|
||
|
||
// 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
|
||
|
||
// 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
|
||
|
||
// blockedTurnStreak counts consecutive turns in which every tool call was
|
||
// blocked by the host (permission, plan mode, hook, or loop guard).
|
||
// stormSig catches a model fixated on one call shape; this catches a model
|
||
// rotating between blocked shapes — alternating tools, reordering a batch,
|
||
// or blockers whose text varies per attempt — which is zero progress all
|
||
// the same. Reset by any turn containing a non-blocked outcome and at the
|
||
// start of each user turn. See applyStormBreaker.
|
||
blockedTurnStreak int
|
||
|
||
// loopGuardArmed / loopGuardReceiptMark let final readiness stand down
|
||
// after a loop guard fired this user turn: once the host has told the model
|
||
// to stop retrying and report the blocker, demanding the receipts that the
|
||
// blocker prevents would restart the loop the guard just broke. The mark is
|
||
// the evidence-ledger receipt count from just before the guarded batch, so
|
||
// real progress — a successful write or command receipt landing after it —
|
||
// revokes the pass, while the bookkeeping the guard itself recommends
|
||
// (ask, todo_write, complete_step) keeps it. Host state, not message text:
|
||
// tool output that merely quotes "[loop guard]" must not unlock readiness.
|
||
// Reset at the start of each user turn. See loopGuardAllowsFinal.
|
||
loopGuardArmed bool
|
||
loopGuardReceiptMark int
|
||
|
||
// repeatSuccessCounts tracks write-like tool calls that have already
|
||
// succeeded in this user turn. This catches the complementary loop shape to
|
||
// stormSig: a model keeps doing the same successful write, so there is no
|
||
// error for the failure-only storm breaker to see.
|
||
repeatSuccessCounts map[string]int
|
||
}
|
||
|
||
// 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 flips the read-only gate. While true, executeOne refuses any
|
||
// non-ReadOnly tool the model calls and returns a "blocked" result instead of
|
||
// running it. The cache-friendly bits — system prompt, tools schema, message
|
||
// history — are left untouched, so the toggle costs nothing in cache hits.
|
||
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))
|
||
}
|
||
|
||
// SetMemoryCompiler updates the Memory v5 runtime used for subsequent turns.
|
||
// It is safe for desktop settings to call while other tabs are idle or running;
|
||
// an already-started turn keeps its own Turn handle and future turns observe the
|
||
// new runtime.
|
||
func (a *Agent) SetMemoryCompiler(rt *memorycompiler.Runtime) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.memoryCompilerMu.Lock()
|
||
a.memoryCompiler = rt
|
||
a.memoryCompilerMu.Unlock()
|
||
a.resetMemoryCompilerInjectionGate()
|
||
}
|
||
|
||
func (a *Agent) SetMemoryCompilerVerbosity(verbosity string) {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.memoryCompilerMu.Lock()
|
||
a.memoryCompilerVerbosity = normalizeMemoryCompilerVerbosity(verbosity)
|
||
a.memoryCompilerMu.Unlock()
|
||
a.resetMemoryCompilerInjectionGate()
|
||
}
|
||
|
||
func (a *Agent) memoryCompilerRuntime() *memorycompiler.Runtime {
|
||
if a == nil {
|
||
return nil
|
||
}
|
||
a.memoryCompilerMu.RLock()
|
||
defer a.memoryCompilerMu.RUnlock()
|
||
return a.memoryCompiler
|
||
}
|
||
|
||
func (a *Agent) memoryCompilerShouldInject() bool {
|
||
if a == nil {
|
||
return false
|
||
}
|
||
a.memoryCompilerMu.RLock()
|
||
defer a.memoryCompilerMu.RUnlock()
|
||
return normalizeMemoryCompilerVerbosity(a.memoryCompilerVerbosity) == MemoryCompilerVerbosityCompact
|
||
}
|
||
|
||
func normalizeMemoryCompilerVerbosity(v string) string {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "compact", "inject", "injected", "contract", "on":
|
||
return MemoryCompilerVerbosityCompact
|
||
default:
|
||
return MemoryCompilerVerbosityObserve
|
||
}
|
||
}
|
||
|
||
// clearClassifierCache 清除 LLM 分类器的缓存(在会话边界调用)
|
||
func (a *Agent) clearClassifierCache() {
|
||
if a == nil || a.classifier == nil {
|
||
return
|
||
}
|
||
if llm, ok := a.classifier.(*llmClassifier); ok && llm.cache != nil {
|
||
llm.cache.Clear()
|
||
}
|
||
}
|
||
|
||
// reviseMemoryCompilerOutcomeForFeedback retroactively downgrades the previous
|
||
// turn's recorded success when the user's immediate follow-up reports the
|
||
// result wrong. The ref is consumed unconditionally so it can never outlive
|
||
// the turn that follows it; the revision itself additionally requires a live
|
||
// runtime and corrective feedback.
|
||
func (a *Agent) reviseMemoryCompilerOutcomeForFeedback(rt *memorycompiler.Runtime, input string) {
|
||
a.memoryCompilerMu.Lock()
|
||
ref := a.lastCompilerOutcome
|
||
a.lastCompilerOutcome = nil
|
||
a.memoryCompilerMu.Unlock()
|
||
if ref == nil || rt == nil {
|
||
return
|
||
}
|
||
if !memorycompiler.IsCorrectiveFeedback(input) {
|
||
return
|
||
}
|
||
rt.ReviseOutcomeFromFeedback(*ref, input)
|
||
}
|
||
|
||
func shouldStartMemoryCompiler(input string) bool {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
// Contract-like leading XML is host-generated control text, not a genuine
|
||
// user task. Let it pass through normally instead of compiling it again and
|
||
// risking nested Memory v5 blocks in previews or future source_event fields.
|
||
return !strings.HasPrefix(trimmed, "<")
|
||
}
|
||
|
||
func shouldInjectMemoryCompilerContractForInput(input string) bool {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
normalized := strings.ToLower(strings.Trim(trimmed, " \t\r\n.!?。!?,,;;::"))
|
||
switch normalized {
|
||
case "", "hello", "hi", "hey", "你好", "您好", "nihao", "thanks", "thank you", "谢谢", "ok", "okay", "好的", "嗯":
|
||
return false
|
||
}
|
||
actionNeedles := []string{
|
||
"fix", "debug", "repair", "resolve", "reproduce",
|
||
"create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
|
||
"review", "inspect", "analyze", "check", "test", "run", "build", "implement", "refactor",
|
||
"continue work", "continue the", "continue this",
|
||
"修复", "调试", "解决", "复现", "创建", "新建", "添加", "编写", "编辑", "修改", "更新",
|
||
"删除", "移除", "重命名", "评审", "检查", "分析", "测试", "运行", "构建", "实现", "重构", "继续处理",
|
||
}
|
||
for _, needle := range actionNeedles {
|
||
if strings.Contains(normalized, needle) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (a *Agent) tryMarkMemoryCompilerInjected(now time.Time) bool {
|
||
if a == nil {
|
||
return false
|
||
}
|
||
a.compilerInjectionMu.Lock()
|
||
defer a.compilerInjectionMu.Unlock()
|
||
if a.compilerInjectionCount >= memoryCompilerInjectionMax {
|
||
return false
|
||
}
|
||
if !a.lastCompilerInjectedAt.IsZero() && now.Sub(a.lastCompilerInjectedAt) < memoryCompilerInjectionCooldown {
|
||
return false
|
||
}
|
||
a.compilerInjectionCount++
|
||
a.lastCompilerInjectedAt = now
|
||
return true
|
||
}
|
||
|
||
func (a *Agent) resetMemoryCompilerInjectionGate() {
|
||
if a == nil {
|
||
return
|
||
}
|
||
a.compilerInjectionMu.Lock()
|
||
defer a.compilerInjectionMu.Unlock()
|
||
a.compilerInjectionCount = 0
|
||
a.lastCompilerInjectedAt = time.Time{}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// SetPlanModeReadOnlyTrustGate installs the optional confirmation path for MCP
|
||
// tools whose read-only flag comes from an external readOnlyHint and bash
|
||
// commands the user may trust as plan-mode read-only.
|
||
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.
|
||
func (a *Agent) SetPreEditHook(fn func(diff.Change)) { a.onPreEdit = fn }
|
||
|
||
// 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
|
||
if s != nil {
|
||
a.rebuildTodoState(s.Snapshot())
|
||
}
|
||
a.resetMemoryCompilerInjectionGate()
|
||
// A session switch breaks the "immediately preceding turn" relationship:
|
||
// the next input belongs to a different conversation, so the pending
|
||
// outcome ref must not be revisable from it.
|
||
a.memoryCompilerMu.Lock()
|
||
a.lastCompilerOutcome = nil
|
||
a.memoryCompilerMu.Unlock()
|
||
// 清除分类缓存(会话边界)
|
||
a.clearClassifierCache()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// flushSteerQueue ends the turn's steer intake: it drains whatever is still
|
||
// queued and persists each entry to the session, exactly as the in-loop
|
||
// consume would have (#6238 — a dropped steer vanished from both the model's
|
||
// context and history). The flushed steers reach the model on the next turn;
|
||
// the Steer event keeps the transcript honest about when they arrived.
|
||
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.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
|
||
a.sink.Emit(event.Event{Kind: event.Steer, Text: 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 configuration knob shown when the MaxSteps guard is
|
||
// hit. Empty defaults to agent.max_steps.
|
||
MaxStepsKey string
|
||
Temperature float64
|
||
Pricing *provider.Pricing // optional, for per-turn cost display
|
||
UsageSource string // optional billable usage source; default executor
|
||
|
||
// Gate is the per-call permission gate. nil disables gating.
|
||
Gate Gate
|
||
|
||
// PlanModeReadOnlyTrustGate confirms untrusted external read-only hints when
|
||
// plan mode would otherwise block them. nil keeps fail-closed behavior.
|
||
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
|
||
|
||
// Jobs is the session's background-job manager (nil disables background tools).
|
||
Jobs *jobs.Manager
|
||
|
||
// 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
|
||
|
||
// 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
|
||
|
||
// PlanModeAllowedTools names extra custom tools the plan-mode policy may treat
|
||
// as read-only. It cannot unlock known blocked tools or unsafe bash commands.
|
||
PlanModeAllowedTools []string
|
||
// PlanModeReadOnlyCommands names concrete shell command prefixes that plan mode
|
||
// may treat as read-only. Shell operators, background execution, and shell
|
||
// interpreter prefixes remain blocked.
|
||
PlanModeReadOnlyCommands []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
|
||
|
||
// MemoryCompiler enables Memory v5 execution trace writeback and cache-safe
|
||
// execution-contract compilation.
|
||
MemoryCompiler *memorycompiler.Runtime
|
||
// MemoryCompilerVerbosity controls provider-visible injection. Empty defaults
|
||
// to observe; compact restores the execution-contract user-turn injection.
|
||
MemoryCompilerVerbosity string
|
||
|
||
// UseMemoryCompilerLLMClassification 启用 LLM 分类来判断任务 vs 聊天
|
||
// 默认 false 时使用启发式分类器
|
||
UseMemoryCompilerLLMClassification bool
|
||
}
|
||
|
||
// 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 = "agent.max_steps"
|
||
}
|
||
maxSubagentDepth := opts.MaxSubagentDepth
|
||
if maxSubagentDepth == 0 {
|
||
maxSubagentDepth = DefaultMaxSubagentDepth
|
||
} else {
|
||
maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
|
||
}
|
||
subagentDepth := opts.SubagentDepth
|
||
if subagentDepth < 0 {
|
||
subagentDepth = 0
|
||
}
|
||
a := &Agent{
|
||
prov: prov,
|
||
tools: tools,
|
||
session: session,
|
||
maxSteps: opts.MaxSteps,
|
||
maxStepsKey: maxStepsKey,
|
||
temperature: opts.Temperature,
|
||
pricing: opts.Pricing,
|
||
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
|
||
sink: sink,
|
||
gate: gate,
|
||
planModeReadOnlyTrust: planModeReadOnlyTrust,
|
||
sandboxEscapeApprover: sandboxEscapeApprover,
|
||
configWriteApprover: configWriteApprover,
|
||
hooks: hooks,
|
||
jobs: opts.Jobs,
|
||
evidence: evidence.NewLedger(),
|
||
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
||
deliveryProfile: opts.DeliveryProfile,
|
||
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,
|
||
planModeAllowedTools: append([]string(nil), opts.PlanModeAllowedTools...),
|
||
planModeReadOnlyCommands: append([]string(nil), opts.PlanModeReadOnlyCommands...),
|
||
subagentDepth: subagentDepth,
|
||
maxSubagentDepth: maxSubagentDepth,
|
||
memoryCompiler: opts.MemoryCompiler,
|
||
memoryCompilerVerbosity: normalizeMemoryCompilerVerbosity(opts.MemoryCompilerVerbosity),
|
||
}
|
||
// 初始化分类器
|
||
if opts.UseMemoryCompilerLLMClassification && prov != nil {
|
||
// 使用 LLM 分类器(Haiku)
|
||
fallback := newHeuristicClassifier()
|
||
a.classifier = newLLMClassifier(prov, fallback)
|
||
} else {
|
||
// 默认使用启发式分类器
|
||
a.classifier = newHeuristicClassifier()
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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.
|
||
func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||
rawInput := input
|
||
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()
|
||
if a.evidence != nil && !a.preserveEvidenceOnce {
|
||
a.evidence.Reset()
|
||
}
|
||
a.preserveEvidenceOnce = false
|
||
a.deliveryCriteriaEstablished = a.hasIncompleteCanonicalCriteria()
|
||
// Classify delivery expectations from the task text. Sub-agent spawners
|
||
// pass the pristine task through Options.ClassifierTaskText (a trusted
|
||
// host channel) because their Run input carries host framing whose
|
||
// incidental verbs — "file tools resolve relative paths" — once classified
|
||
// every workspace-wrapped subagent prompt as a mutation request and
|
||
// deadlocked read-only subagents. Without the override the raw input is
|
||
// classified verbatim: stripping user-controllable markup here would let
|
||
// input dressed up as host framing disarm the delivery gates.
|
||
classifierInput := a.classifierTaskText
|
||
if strings.TrimSpace(classifierInput) == "" {
|
||
classifierInput = rawInput
|
||
}
|
||
a.deliveryTaskExpected = deliveryTaskNeedsEvidence(classifierInput)
|
||
a.deliveryMutationExpected = deliveryTaskNeedsMutation(classifierInput) && registryHasWriterTools(a.tools)
|
||
a.repeatSuccessCounts = nil
|
||
a.blockedTurnStreak = 0
|
||
a.loopGuardArmed = false
|
||
a.loopGuardReceiptMark = 0
|
||
a.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||
memoryCompilerInput := rawInput
|
||
if sourceInput, ok := MemoryCompilerSourceInputFromContext(ctx); ok {
|
||
memoryCompilerInput = sourceInput
|
||
}
|
||
input = a.withTurnPreferences(rawInput)
|
||
// Consume the previous turn's outcome ref on every non-synthetic turn,
|
||
// even while the runtime is nil (/memory-v5 off): revision must only ever
|
||
// target the immediately preceding turn.
|
||
if !MemoryCompilerSkipFromContext(ctx) {
|
||
a.reviseMemoryCompilerOutcomeForFeedback(a.memoryCompilerRuntime(), memoryCompilerInput)
|
||
}
|
||
if memCompiler := a.memoryCompilerRuntime(); memCompiler != nil && !MemoryCompilerSkipFromContext(ctx) && shouldStartMemoryCompiler(memoryCompilerInput) {
|
||
// 使用分类器判断是否为任务
|
||
isTask := true // 默认为任务
|
||
var classifyErr error
|
||
if a.classifier != nil {
|
||
isTask, classifyErr = a.classifier.IsTask(ctx, memoryCompilerInput)
|
||
if classifyErr != nil {
|
||
// 分类失败时降级到启发式分类器
|
||
isTask = shouldInjectMemoryCompilerContractForInput(memoryCompilerInput)
|
||
}
|
||
}
|
||
|
||
// 只有任务才启动 Memory v5
|
||
if isTask {
|
||
if compiledInput, turn := memCompiler.StartTurn(ctx, memoryCompilerInput, a.session.Snapshot()); turn != nil {
|
||
injected := strings.TrimSpace(compiledInput) != "" &&
|
||
a.memoryCompilerShouldInject() &&
|
||
a.tryMarkMemoryCompilerInjected(time.Now())
|
||
if !injected {
|
||
turn.SuppressInjection()
|
||
}
|
||
a.compilerTurn = turn
|
||
a.emitMemoryCompilerStats(turn)
|
||
defer func() {
|
||
turn.Finish(runErr)
|
||
ref := turn.OutcomeRef()
|
||
a.memoryCompilerMu.Lock()
|
||
a.lastCompilerOutcome = &ref
|
||
a.memoryCompilerMu.Unlock()
|
||
if a.compilerTurn == turn {
|
||
a.compilerTurn = nil
|
||
}
|
||
}()
|
||
if injected {
|
||
input = a.withTurnPreferences(compiledInput)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: input, Images: userImages(ctx)})
|
||
|
||
finalReadinessBlocks := 0
|
||
readinessReceiptMark := -1
|
||
emptyFinalBlocks := 0
|
||
handoffNudges := 0
|
||
usedAnyTool := false
|
||
streamRecoveries := 0
|
||
graceRound := false
|
||
executorHandoff := a.executorHandoffGuard && strings.Contains(input, executorHandoffMarker)
|
||
for step := 0; a.maxSteps <= 0 || step < a.maxSteps || graceRound; step++ {
|
||
// Consume a queued steer and persist it to the session so it
|
||
// survives tab switches and history replay. The model sees it as
|
||
// guidance (with a prefix), not a new task. One cache miss per
|
||
// steer is unavoidable — the model must see the new instruction.
|
||
if text, ok := a.consumeSteer(); ok {
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
|
||
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
|
||
}
|
||
schemas := a.tools.Schemas()
|
||
prefixShape := a.capturePrefixShape(schemas)
|
||
prevPrefixShape := a.lastPrefixShape
|
||
if !a.haveLastPrefixShape {
|
||
prevPrefixShape = prefixShape
|
||
}
|
||
|
||
text, reasoning, signature, calls, usage, interrupted, partialToolStarted, err := a.stream(ctx, step+1)
|
||
if err != nil {
|
||
if interrupted && streamRecoveries < maxStreamRecoveries {
|
||
streamRecoveries++
|
||
if hasVisibleFinalAnswer(text) {
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleAssistant,
|
||
Content: text,
|
||
ReasoningContent: reasoning,
|
||
ReasoningSignature: signature,
|
||
MemoryCitations: a.memoryCitations(),
|
||
WorkDurationMs: workDurationMs(),
|
||
})
|
||
}
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleUser,
|
||
Content: a.withTurnPreferences(streamRecoveryMessage(hasVisibleFinalAnswer(text), partialToolStarted)),
|
||
})
|
||
a.sink.Emit(event.Event{Kind: event.Retrying, RetryAttempt: streamRecoveries, RetryMax: maxStreamRecoveries})
|
||
step-- // recovery retries do not consume the tool-round maxSteps budget
|
||
continue
|
||
}
|
||
return err
|
||
}
|
||
streamRecoveries = 0
|
||
cacheDiagnostics := CompareShape(prevPrefixShape, prefixShape, usage)
|
||
a.lastPrefixShape = prefixShape
|
||
a.haveLastPrefixShape = true
|
||
if usage != nil && usage.TotalTokens > 0 {
|
||
a.sink.Emit(event.Event{Kind: event.Usage, Usage: usage, Pricing: a.pricing,
|
||
UsageSource: a.usageSource,
|
||
CacheDiagnostics: &cacheDiagnostics,
|
||
SessionHit: int(a.sessCacheHit.Load()), SessionMiss: int(a.sessCacheMiss.Load())})
|
||
}
|
||
if msg, ok := finishReasonMessage(usage); ok {
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: msg})
|
||
}
|
||
|
||
// Keep reasoning_content on the assistant turn for display and session
|
||
// archive. It is NOT re-uploaded to the API: the openai provider drops it
|
||
// when building the request, since re-sent reasoning is billable prompt
|
||
// input for no cache or coherence gain.
|
||
calls = a.withPreviewFileDiffs(calls)
|
||
a.warnMissingToolCallReasoning(calls, reasoning)
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleAssistant,
|
||
Content: text,
|
||
ReasoningContent: reasoning,
|
||
ReasoningSignature: signature,
|
||
ToolCalls: calls,
|
||
MemoryCitations: a.memoryCitations(),
|
||
WorkDurationMs: workDurationMs(),
|
||
})
|
||
|
||
if len(calls) == 0 {
|
||
readiness := a.finalReadinessCheck()
|
||
if readiness.reason != "" {
|
||
// A block only counts against the base budget when the model made
|
||
// no host-observable progress since the previous block. A turn that
|
||
// keeps earning receipts (fix → verify → review the newest edit) is
|
||
// converging and gets extra nudges up to the hard cap; a stalled
|
||
// turn still fails after maxFinalReadinessBlocks.
|
||
progressed := readinessReceiptMark >= 0 && a.evidence != nil && a.evidence.Len() > readinessReceiptMark
|
||
if a.evidence != nil {
|
||
readinessReceiptMark = a.evidence.Len()
|
||
}
|
||
finalReadinessBlocks++
|
||
exhausted := finalReadinessBlocks >= maxFinalReadinessBlocksWithProgress ||
|
||
(finalReadinessBlocks >= maxFinalReadinessBlocks && !progressed)
|
||
result := evidence.ReadinessBlocked
|
||
if exhausted {
|
||
result = evidence.ReadinessErrored
|
||
event.RecordReadinessAudit(a.sink, readiness.audit(result, false))
|
||
return &FinalReadinessError{Attempts: finalReadinessBlocks, Reason: readiness.reason}
|
||
}
|
||
event.RecordReadinessAudit(a.sink, readiness.audit(result, false))
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeFinalReadiness, Text: finalReadinessNoticeText(), Detail: readiness.reason})
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(finalReadinessRetryMessage(readiness.reason))})
|
||
a.maybeCompact(ctx, usage)
|
||
continue
|
||
}
|
||
if !hasVisibleFinalAnswer(text) {
|
||
emptyFinalBlocks++
|
||
if emptyFinalBlocks >= maxEmptyFinalBlocks {
|
||
return fmt.Errorf("model finished without a visible final answer %d times", emptyFinalBlocks)
|
||
}
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeEmptyFinal, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.prov.Name(), usage, len(reasoning))})
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(emptyFinalRetryMessage())})
|
||
a.maybeCompact(ctx, usage)
|
||
continue
|
||
}
|
||
if executorHandoff && !usedAnyTool && handoffNudges < maxExecutorHandoffNudges && shouldNudgeExecutorHandoff(input, text) {
|
||
handoffNudges++
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeExecutorHandoff, Text: executorHandoffNoticeText(), Detail: "executor answered without taking any action; nudging it to use its tools"})
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(executorHandoffRetryMessage())})
|
||
a.maybeCompact(ctx, usage)
|
||
continue
|
||
}
|
||
if readiness.applies {
|
||
event.RecordReadinessAudit(a.sink, readiness.audit(evidence.ReadinessAllowed, finalReadinessBlocks > 0))
|
||
}
|
||
if a.steerQueueLen() > 0 {
|
||
continue
|
||
}
|
||
// A final-answer turn otherwise skips compaction, so a large context
|
||
// carries into the next turn un-folded and can overflow the model window.
|
||
// No-op below the trigger, so normal turns keep their warm cache.
|
||
a.maybeCompact(ctx, usage)
|
||
return nil // model gave a final answer
|
||
}
|
||
emptyFinalBlocks = 0
|
||
usedAnyTool = true
|
||
|
||
// Grace round guard: if we already gave the model one extra response
|
||
// and it still wants to call tools, stop here.
|
||
if graceRound {
|
||
return &maxStepsPause{steps: a.maxSteps, key: a.maxStepsKey}
|
||
}
|
||
|
||
results, images := a.executeBatch(ctx, calls)
|
||
for i, call := range calls {
|
||
a.session.Add(provider.Message{
|
||
Role: provider.RoleTool,
|
||
Content: results[i],
|
||
Images: images[i],
|
||
ToolCallID: call.ID,
|
||
Name: call.Name,
|
||
})
|
||
}
|
||
// If the context was cancelled during tool execution, return after storing
|
||
// the batch results so the session keeps paired tool-call history.
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
|
||
// The prompt only grows from here; compact before the next turn so it
|
||
// stays within the model's window.
|
||
a.maybeCompact(ctx, usage)
|
||
|
||
// When the tool-call budget runs out this round, give the model
|
||
// one grace round to produce a final answer from completed work.
|
||
if a.maxSteps > 0 && step+1 >= a.maxSteps {
|
||
graceRound = true
|
||
nudge := fmt.Sprintf("Do not call any more tools — your tool-call round limit (%s) has been reached. Instead, synthesize a final answer from all the work already completed: summarize what was accomplished, what remains to be done, and any decisions the user should make. The user can increase %s or continue in the next turn if more work is needed.", a.maxStepsKey, a.maxStepsKey)
|
||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeToolBudget, Text: toolBudgetNoticeText(), Detail: fmt.Sprintf("budget (%s=%d) exhausted: one grace round to finalize", a.maxStepsKey, a.maxSteps)})
|
||
}
|
||
}
|
||
// Only reached when a positive maxSteps guard is configured. The work so far
|
||
// is already in the session, so the user can just send another message to pick
|
||
// up where it left off.
|
||
return &maxStepsPause{steps: a.maxSteps, key: a.maxStepsKey}
|
||
}
|
||
|
||
// warnMissingToolCallReasoning surfaces a thinking-mode tool_calls turn that
|
||
// arrived without reasoning text only when the provider/model is expected to
|
||
// emit it. The turn is still saved and the replay still succeeds (the wire
|
||
// layer always emits the reasoning_content key on such turns), but models that
|
||
// rely on tool-call reasoning continue without their chain-of-thought context,
|
||
// so that degradation is worth one visible warning. Exactly one per session:
|
||
// the shape is endpoint-conditional (observed on the official DeepSeek API as
|
||
// well as behind gateways) and tends to repeat for every round once it starts,
|
||
// so per-round notices bury the transcript without adding signal (#6259).
|
||
func (a *Agent) warnMissingToolCallReasoning(calls []provider.ToolCall, reasoning string) {
|
||
if len(calls) == 0 || !provider.WarnOnMissingToolCallReasoning(a.prov) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(reasoning) != "" {
|
||
return
|
||
}
|
||
if a.warnedMissingToolCallReasoning {
|
||
return
|
||
}
|
||
a.warnedMissingToolCallReasoning = true
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
|
||
Text: fmt.Sprintf("%s returned tool calls without reasoning_content; continuing, but thinking context is lost on such turns (shown once per session)", a.prov.Name()),
|
||
Detail: fmt.Sprintf("this round carried %d tool call(s) and no reasoning. Whether reasoning accompanies tool calls is endpoint-side behavior; the turn is saved and replayed with an empty reasoning_content key, which the API accepts. Later rounds with the same shape stay silent for the rest of the session.", len(calls))})
|
||
}
|
||
|
||
// 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
|
||
// matches on it to surface the pause instead of degrading the turn to
|
||
// executor-only.
|
||
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)
|
||
}
|
||
|
||
func (a *Agent) emitMemoryCompilerStats(turn *memorycompiler.Turn) {
|
||
if a == nil || turn == nil {
|
||
return
|
||
}
|
||
m := turn.Metrics()
|
||
a.sink.Emit(event.Event{Kind: event.MemoryCompilerStatsEvent, MemoryCompiler: &event.MemoryCompilerStats{
|
||
Injected: m.Injected,
|
||
UsefulIR: m.UsefulIR,
|
||
CompiledTokens: m.CompiledTokens,
|
||
IROverheadTokens: m.IROverheadTokens,
|
||
MemoryReferences: m.MemoryReferences,
|
||
Constraints: m.Constraints,
|
||
RiskNotes: m.RiskNotes,
|
||
ExecutionSteps: m.ExecutionSteps,
|
||
TotalNodes: m.TotalNodes,
|
||
HighSignalNodes: m.HighSignalNodes,
|
||
ToolResultNodes: m.ToolResultNodes,
|
||
DecisionNodes: m.DecisionNodes,
|
||
StrategyCount: m.StrategyCount,
|
||
LearningCount: m.LearningCount,
|
||
}})
|
||
}
|
||
|
||
func (a *Agent) finalReadinessFailure() string {
|
||
return a.finalReadinessCheck().reason
|
||
}
|
||
|
||
// GoalReadinessFailure returns the final-readiness failure reason — a summary of
|
||
// incomplete todos and unverified project checks — or empty string if none.
|
||
// Exported so the Controller can gate [goal:complete] on evidence.
|
||
func (a *Agent) GoalReadinessFailure() string {
|
||
return a.finalReadinessFailure()
|
||
}
|
||
|
||
type finalReadinessCheck struct {
|
||
applies bool
|
||
reason string
|
||
missingProjectChecks int
|
||
incompleteTodos int
|
||
missingAcceptanceCriteria int
|
||
missingVerification int
|
||
missingReview int
|
||
missingSignoff int
|
||
missingActionEvidence int
|
||
missingMutation int
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
|
||
func (a *Agent) finalReadinessCheck() finalReadinessCheck {
|
||
if a.evidence == nil {
|
||
return finalReadinessCheck{}
|
||
}
|
||
var missing []string
|
||
out := finalReadinessCheck{}
|
||
if !a.planMode.Load() {
|
||
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
|
||
if a.deliveryProfile {
|
||
if mutation, ok := a.evidence.LatestSuccessfulMutationIndex(); ok {
|
||
writer, hasWriter = mutation, true
|
||
deliveryMutation = true
|
||
}
|
||
if a.deliveryTaskExpected && !a.evidence.HasSuccessfulWorkReceipt() {
|
||
out.missingActionEvidence++
|
||
missing = append(missing, "perform host-observable work for this technical task 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
|
||
missing = append(missing, msg)
|
||
}
|
||
}
|
||
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 {
|
||
if !a.deliveryCriteriaEstablished {
|
||
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
|
||
}
|
||
|
||
// 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 finalReadinessNoticeText() string {
|
||
return "Task status needs one more check; asking the assistant to finish or explain what is blocking it."
|
||
}
|
||
|
||
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
|
||
a.todoMu.Lock()
|
||
a.todoState = append([]evidence.TodoItem(nil), 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(todos)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func deliveryTaskNeedsEvidence(input string) bool {
|
||
isTask, err := newHeuristicClassifier().IsTask(context.Background(), input)
|
||
return err == nil && isTask
|
||
}
|
||
|
||
func deliveryTaskNeedsMutation(input string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(input))
|
||
for _, phrase := range []string{
|
||
"do not fix", "don't fix", "without changing", "without modifying", "analysis only", "review only",
|
||
"不要修复", "不要修改", "不要改动", "只分析", "仅分析", "只检查", "仅检查", "只评审", "仅评审",
|
||
} {
|
||
if strings.Contains(normalized, phrase) {
|
||
return false
|
||
}
|
||
}
|
||
for _, needle := range []string{
|
||
"fix", "repair", "resolve", "create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
|
||
"implement", "refactor", "apply", "install", "publish", "commit", "push", "continue work",
|
||
"修复", "解决", "创建", "新建", "添加", "编写", "编辑", "修改", "更新", "删除", "移除", "重命名", "实现", "重构",
|
||
"实施", "落地", "安装", "发布", "提交", "继续处理",
|
||
} {
|
||
if containsTaskNeedle(normalized, needle) {
|
||
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 || canonicalTodoStatus(a.todoState[m.Index-1].Status) == "completed" {
|
||
a.todoMu.Unlock()
|
||
return
|
||
}
|
||
a.todoState[m.Index-1].Status = "completed"
|
||
promoteNextPendingTodo(a.todoState)
|
||
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 promoteNextPendingTodo(todos []evidence.TodoItem) {
|
||
for _, t := range todos {
|
||
if canonicalTodoStatus(t.Status) == "in_progress" {
|
||
return
|
||
}
|
||
}
|
||
for i := range todos {
|
||
if canonicalTodoStatus(todos[i].Status) == "pending" {
|
||
todos[i].Status = "in_progress"
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = append([]evidence.TodoItem(nil), 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 && canonicalTodoStatus(todos[m.Index-1].Status) != "completed" {
|
||
todos[m.Index-1].Status = "completed"
|
||
promoteNextPendingTodo(todos)
|
||
}
|
||
}
|
||
}
|
||
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 finalReadinessRetryMessage(reason string) string {
|
||
return "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: " + reason + ". Run only the required tool calls, then answer when readiness is satisfied. Prefer signing off completed work with complete_step and updating todo_write from existing receipts; do not run exploratory bash commands just to satisfy readiness. If a permission, plan-mode, hook, or loop-guard block prevents the required receipt, do not keep retrying the blocked command with different wording. If the blocked item needs user input, a user-owned choice, or manual 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."
|
||
}
|
||
|
||
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) != ""
|
||
}
|
||
|
||
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."
|
||
}
|
||
|
||
func streamRecoveryMessage(hasPartialText, hadPartialTool bool) string {
|
||
switch {
|
||
case hadPartialTool:
|
||
return "The previous assistant response was interrupted while a tool call was streaming. Continue the same task now. If a tool is still needed, issue a fresh complete tool call from scratch; do not rely on any partial tool-call arguments from the interrupted stream."
|
||
case hasPartialText:
|
||
return "The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above. Do not repeat text that is already visible."
|
||
default:
|
||
return "The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now and provide the next useful response."
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
func (a *Agent) stream(ctx context.Context, turn int) (string, string, string, []provider.ToolCall, *provider.Usage, bool, bool, error) {
|
||
ctx = provider.WithRetryNotify(ctx, func(info provider.RetryInfo) {
|
||
a.sink.Emit(event.Event{Kind: event.Retrying, RetryAttempt: info.Attempt, RetryMax: info.Max})
|
||
})
|
||
ch, err := a.prov.Stream(ctx, provider.Request{
|
||
Messages: a.session.Messages,
|
||
Tools: a.tools.Schemas(),
|
||
Temperature: provider.OptionalTemperature(a.temperature),
|
||
})
|
||
if err != nil {
|
||
return "", "", "", nil, nil, false, false, 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 calls []provider.ToolCall
|
||
var usage *provider.Usage
|
||
var partialToolStarted bool
|
||
var lastArgProgress time.Time
|
||
finishReasoning := func() (stored, display string) {
|
||
original := reasoning.String()
|
||
display = original
|
||
if transformReasoning && original != "" {
|
||
display = a.hooks.PostLLMCall(ctx, original, turn)
|
||
if display != "" {
|
||
a.sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
|
||
}
|
||
}
|
||
stored = display
|
||
if signature != "" || (len(calls) > 0 && provider.RequiresToolCallReasoning(a.prov)) {
|
||
stored = original
|
||
}
|
||
return stored, display
|
||
}
|
||
for {
|
||
var chunk provider.Chunk
|
||
select {
|
||
case <-ctx.Done():
|
||
stored, _ := finishReasoning()
|
||
return text.String(), stored, signature, calls, usage, false, partialToolStarted, ctx.Err()
|
||
case c, ok := <-ch:
|
||
if !ok {
|
||
if err := ctx.Err(); err != nil {
|
||
stored, _ := finishReasoning()
|
||
return text.String(), stored, signature, calls, usage, false, partialToolStarted, err
|
||
}
|
||
stored, display := finishReasoning()
|
||
if text.Len() > 0 || display != "" {
|
||
a.sink.Emit(event.Event{
|
||
Kind: event.Message,
|
||
Text: StripGoalMarkers(text.String()),
|
||
Reasoning: display,
|
||
MemoryCitations: a.memoryCitations(),
|
||
})
|
||
}
|
||
return text.String(), stored, signature, calls, usage, false, false, nil
|
||
}
|
||
chunk = c
|
||
}
|
||
switch chunk.Type {
|
||
case provider.ChunkReasoning:
|
||
reasoning.WriteString(chunk.Text)
|
||
if chunk.Signature != "" {
|
||
signature = chunk.Signature
|
||
}
|
||
if chunk.Text != "" && !transformReasoning {
|
||
a.sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
|
||
}
|
||
case provider.ChunkText:
|
||
text.WriteString(chunk.Text)
|
||
a.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 {
|
||
a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
||
ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true,
|
||
}})
|
||
}
|
||
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 tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
|
||
lastArgProgress = time.Now()
|
||
a.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,
|
||
}})
|
||
}
|
||
case provider.ChunkToolCall:
|
||
partialToolStarted = true
|
||
calls = append(calls, *chunk.ToolCall)
|
||
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()
|
||
return text.String(), stored, signature, calls, usage, true, partialToolStarted, chunk.Err
|
||
}
|
||
return "", "", "", nil, nil, false, false, chunk.Err
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *Agent) memoryCitations() []provider.MemoryCitation {
|
||
if a.compilerTurn == nil {
|
||
return nil
|
||
}
|
||
return a.compilerTurn.MemoryCitations()
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
// 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. The second return carries each call's tool-result images (nil
|
||
// for most calls), aligned by index with the first.
|
||
func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) ([]string, [][]string) {
|
||
for _, c := range calls {
|
||
t, ok := a.tools.Get(c.Name)
|
||
ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.Arguments, ReadOnly: ok && t.ReadOnly()}
|
||
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})
|
||
}
|
||
|
||
results := make([]string, len(calls))
|
||
outcomes := make([]toolOutcome, len(calls))
|
||
durations := make([]int64, len(calls))
|
||
// 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()
|
||
}
|
||
run := func(i int) {
|
||
start := time.Now()
|
||
outcomes[i] = a.executeOne(ctx, calls[i])
|
||
durations[i] = time.Since(start).Milliseconds()
|
||
results[i] = outcomes[i].output
|
||
}
|
||
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
|
||
}
|
||
|
||
for _, batch := range partitionToolCalls(a.tools, calls) {
|
||
if ctx.Err() != nil {
|
||
markCancelled(batch.start)
|
||
break
|
||
}
|
||
if batch.parallel && batch.end-batch.start > 1 {
|
||
ranUntil := runParallel(ctx, batch.start, batch.end, run)
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
run(i)
|
||
// 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 {
|
||
break
|
||
}
|
||
}
|
||
|
||
for i, c := range calls {
|
||
o := outcomes[i]
|
||
t, ok := a.tools.Get(c.Name)
|
||
a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
|
||
ID: c.ID,
|
||
Name: c.Name,
|
||
Args: c.Arguments,
|
||
Output: o.output,
|
||
Err: o.errMsg,
|
||
ReadOnly: ok && t.ReadOnly(),
|
||
Truncated: o.truncated,
|
||
DurationMs: durations[i],
|
||
}})
|
||
if o.truncated && o.truncMsg != "" {
|
||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
|
||
}
|
||
}
|
||
if a.compilerTurn != nil {
|
||
records := make([]memorycompiler.ToolRecord, 0, len(calls))
|
||
for i, c := range calls {
|
||
o := outcomes[i]
|
||
t, ok := a.tools.Get(c.Name)
|
||
records = append(records, memorycompiler.ToolRecord{
|
||
ID: c.ID,
|
||
Name: c.Name,
|
||
Args: c.Arguments,
|
||
Output: o.output,
|
||
Error: o.errMsg,
|
||
ReadOnly: ok && t.ReadOnly(),
|
||
Blocked: o.blocked,
|
||
DurationMs: durations[i],
|
||
Truncated: o.truncated,
|
||
})
|
||
}
|
||
a.compilerTurn.RecordToolResults(records)
|
||
}
|
||
if !cancelled {
|
||
a.applyStormBreaker(calls, outcomes, results, receiptMark)
|
||
}
|
||
images := make([][]string, len(calls))
|
||
for i := range outcomes {
|
||
images[i] = outcomes[i].images
|
||
}
|
||
return results, images
|
||
}
|
||
|
||
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, ok := a.tools.Get(out[i].Name)
|
||
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 are read-only but never join a parallel run: they
|
||
// read the turn's evidence ledger, so every prior call's receipt must be recorded
|
||
// before they run.
|
||
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 {
|
||
if name == "complete_step" || name == "todo_write" {
|
||
return false
|
||
}
|
||
t, ok := r.Get(name)
|
||
return ok && 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
|
||
}
|
||
i := i
|
||
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
|
||
|
||
// 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 stuck retrying a blocked action; 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
|
||
}
|
||
|
||
// executeOne runs a single tool call. It is pure with respect to the event sink
|
||
// — the caller emits ToolDispatch/ToolResult — so it is safe to invoke from
|
||
// parallel goroutines.
|
||
func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutcome {
|
||
t, ok := a.tools.Get(call.Name)
|
||
if !ok {
|
||
return toolOutcome{
|
||
output: fmt.Sprintf("error: unknown tool %q", call.Name),
|
||
errMsg: fmt.Sprintf("unknown tool %q", call.Name),
|
||
}
|
||
}
|
||
if out, blocked := a.repeatedSuccessBlock(call, t); blocked {
|
||
return toolOutcome{
|
||
output: out,
|
||
blocked: true,
|
||
errMsg: loopGuardBlockErrMsg,
|
||
}
|
||
}
|
||
if out, blocked := a.staleAnchorEditBlock(call); blocked {
|
||
return toolOutcome{
|
||
output: out,
|
||
blocked: true,
|
||
errMsg: "blocked: fresh read required",
|
||
}
|
||
}
|
||
planModeTrustedReadOnly := false
|
||
if a.planMode.Load() {
|
||
// Translate the tool's optional plan-mode self-report into the policy's
|
||
// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
|
||
safety := planmode.PlanSafetyUnknown
|
||
if c, ok := t.(tool.PlanModeClassifier); ok {
|
||
if c.PlanModeSafe() {
|
||
safety = planmode.PlanSafetySafe
|
||
} else {
|
||
safety = planmode.PlanSafetyUnsafe
|
||
}
|
||
}
|
||
// External tools (MCP) whose ReadOnly() is only a server-reported
|
||
// readOnlyHint are not trusted by plan mode's read-only fast path.
|
||
untrusted := false
|
||
if u, ok := t.(tool.PlanModeUntrustedReadOnly); ok {
|
||
untrusted = u.PlanModeUntrustedReadOnly()
|
||
}
|
||
if decision := a.planModeDecision(call.Name, t.ReadOnly(), untrusted, safety, json.RawMessage(call.Arguments)); decision.Blocked {
|
||
trustAllowed := false
|
||
if decision.ReadOnlyCommandTrust != nil {
|
||
if allow, outcome, handled := a.checkPlanModeBashReadOnlyTrust(ctx, call, decision.ReadOnlyCommandTrust); handled {
|
||
if !allow {
|
||
return outcome
|
||
}
|
||
trustAllowed = true
|
||
planModeTrustedReadOnly = true
|
||
}
|
||
} else if t.ReadOnly() && untrusted && safety != planmode.PlanSafetyUnsafe {
|
||
if allow, outcome, handled := a.checkPlanModeMCPReadOnlyTrust(ctx, call, t); handled {
|
||
if !allow {
|
||
return outcome
|
||
}
|
||
trustAllowed = true
|
||
}
|
||
}
|
||
if !trustAllowed {
|
||
return toolOutcome{
|
||
output: decision.Message,
|
||
blocked: true,
|
||
errMsg: "blocked: plan mode is read-only",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Resolve proxy tools (use_capability) to the real MCP target before
|
||
// permission, hooks, and evidence. Provider transcript keeps call.Name.
|
||
permName := call.Name
|
||
permArgs := json.RawMessage(call.Arguments)
|
||
execTool := t
|
||
execArgs := json.RawMessage(call.Arguments)
|
||
evidenceName := call.Name
|
||
evidenceArgs := json.RawMessage(call.Arguments)
|
||
readOnly := t.ReadOnly() || planModeTrustedReadOnly
|
||
var resolved tool.ResolvedCall
|
||
if resolver, ok := t.(tool.CallResolver); ok {
|
||
rc, rerr := resolver.ResolveCall(ctx, json.RawMessage(call.Arguments))
|
||
if rerr != nil {
|
||
return toolOutcome{
|
||
output: fmt.Sprintf("error: %v", rerr),
|
||
errMsg: firstLine(rerr.Error()),
|
||
}
|
||
}
|
||
resolved = rc
|
||
if rc.TargetName != "" {
|
||
permName = rc.TargetName
|
||
evidenceName = rc.TargetName
|
||
}
|
||
if len(rc.Args) > 0 {
|
||
permArgs = rc.Args
|
||
evidenceArgs = rc.Args
|
||
execArgs = rc.Args
|
||
}
|
||
if rc.Target != nil {
|
||
execTool = rc.Target
|
||
}
|
||
readOnly = rc.ReadOnly || planModeTrustedReadOnly
|
||
if rc.TargetName != "" && rc.TargetName != call.Name {
|
||
EmitProxyAudit(a.sink, rc)
|
||
}
|
||
if rc.SkipExecute {
|
||
// Resolution completed without target execution; still record a meta receipt.
|
||
// A connected mcp-server call completes during resolution by listing
|
||
// its live tools, so account for that successful call here too.
|
||
if rc.ProxyAction == "call" && !rc.Unavailable {
|
||
a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), nil)
|
||
}
|
||
result := secrets.RedactToolOutput(rc.Result)
|
||
if a.evidence != nil {
|
||
// inspect/decline are not mutations; unavailable call targets are not success.
|
||
success := !rc.Unavailable
|
||
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), success, true)
|
||
a.evidence.Record(rec)
|
||
}
|
||
if rc.Unavailable {
|
||
return toolOutcome{output: result, errMsg: firstLine(rc.UnavailableReason)}
|
||
}
|
||
body, truncMsg := truncateToolOutput(result)
|
||
return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}
|
||
}
|
||
}
|
||
|
||
// A proxy resolution can point at a write-capable target even though the
|
||
// proxy tool itself reports read-only: the pre-resolution plan-mode check
|
||
// above only judged the proxy's own claim. Re-run the policy against the
|
||
// real target's name, read-only flag, trust, and safety before any gate
|
||
// lets the call through.
|
||
if resolved.TargetName != "" && a.planMode.Load() {
|
||
safety := planmode.PlanSafetyUnknown
|
||
if c, ok := execTool.(tool.PlanModeClassifier); ok {
|
||
if c.PlanModeSafe() {
|
||
safety = planmode.PlanSafetySafe
|
||
} else {
|
||
safety = planmode.PlanSafetyUnsafe
|
||
}
|
||
}
|
||
untrusted := false
|
||
if u, ok := execTool.(tool.PlanModeUntrustedReadOnly); ok {
|
||
untrusted = u.PlanModeUntrustedReadOnly()
|
||
}
|
||
if decision := a.planModeDecision(permName, resolved.ReadOnly, untrusted, safety, permArgs); decision.Blocked {
|
||
trustAllowed := false
|
||
if resolved.ReadOnly && untrusted && safety != planmode.PlanSafetyUnsafe {
|
||
resolvedCall := provider.ToolCall{ID: call.ID, Name: permName, Arguments: string(permArgs)}
|
||
if allow, outcome, handled := a.checkPlanModeMCPReadOnlyTrust(ctx, resolvedCall, execTool); handled {
|
||
if !allow {
|
||
return outcome
|
||
}
|
||
trustAllowed = true
|
||
}
|
||
}
|
||
if !trustAllowed {
|
||
return toolOutcome{
|
||
output: decision.Message,
|
||
blocked: true,
|
||
errMsg: "blocked: plan mode is read-only",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if a.deliveryProfile && evidence.ToolCallRequiresDeliveryCriteria(evidenceName, evidenceArgs, readOnly) && !a.deliveryCriteriaEstablished {
|
||
return toolOutcome{
|
||
output: "blocked: delivery-first mode requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.",
|
||
blocked: true,
|
||
errMsg: "blocked: delivery acceptance criteria required",
|
||
}
|
||
}
|
||
if a.gate != nil {
|
||
allow, reason, err := a.gate.Check(ctx, permName, permArgs, readOnly)
|
||
if err != nil {
|
||
return toolOutcome{
|
||
output: fmt.Sprintf("blocked: %s (%v)", reason, err),
|
||
blocked: true,
|
||
errMsg: fmt.Sprintf("blocked: %v", err),
|
||
}
|
||
}
|
||
if !allow {
|
||
return toolOutcome{
|
||
output: "blocked: " + reason,
|
||
blocked: true,
|
||
errMsg: "blocked by permission policy",
|
||
}
|
||
}
|
||
}
|
||
// PreToolUse hooks run after permission is granted but before the call: a
|
||
// gating hook (exit 2) refuses it, surfaced to the model like a gate denial.
|
||
// Proxy tools fire hooks against the real MCP target name and arguments.
|
||
if a.hooks != nil {
|
||
if block, msg := a.hooks.PreToolUse(ctx, permName, permArgs); block {
|
||
if msg == "" {
|
||
msg = "blocked by a PreToolUse hook"
|
||
}
|
||
return toolOutcome{
|
||
output: "blocked: " + msg,
|
||
blocked: true,
|
||
errMsg: "blocked by PreToolUse hook",
|
||
}
|
||
}
|
||
}
|
||
// Checkpoint the file this writer is about to change, so the turn can be
|
||
// rewound. Fires after all gating (the edit is cleared to run) and only for
|
||
// tools that can describe their change; a Preview error means the edit will
|
||
// likely fail anyway, so we skip rather than snapshot a stale state.
|
||
if a.onPreEdit != nil && !readOnly {
|
||
if pv, ok := execTool.(tool.Previewer); ok {
|
||
if change, perr := pv.Preview(execArgs); perr == nil {
|
||
a.onPreEdit(change)
|
||
}
|
||
}
|
||
}
|
||
cctx := withCallContext(ctx, call.ID, a.sink, a.asker, a.planMode.Load())
|
||
cctx = WithSubagentDepth(cctx, a.subagentDepth)
|
||
if a.evidence != nil {
|
||
cctx = evidence.WithLedger(cctx, a.evidence)
|
||
cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot())
|
||
}
|
||
if len(a.projectChecks) > 0 {
|
||
cctx = instruction.WithChecks(cctx, a.projectChecks)
|
||
}
|
||
if a.jobs != nil {
|
||
cctx = jobs.WithManager(cctx, a.jobs)
|
||
}
|
||
if a.sandboxEscapeApprover != nil {
|
||
cctx = sandbox.WithEscapeApprover(cctx, a.sandboxEscapeApprover)
|
||
}
|
||
if a.configWriteApprover != nil {
|
||
cctx = tool.WithConfigWriteApprover(cctx, a.configWriteApprover)
|
||
}
|
||
if v := a.responseLanguage.Load(); v != nil {
|
||
if lang, ok := v.(string); ok {
|
||
cctx = WithResponseLanguagePreference(cctx, lang)
|
||
}
|
||
}
|
||
if v := a.reasoningLanguage.Load(); v != nil {
|
||
if lang, ok := v.(string); ok {
|
||
cctx = WithReasoningLanguagePreference(cctx, lang)
|
||
}
|
||
}
|
||
if a.memQueue != nil {
|
||
cctx = memory.WithQueue(cctx, a.memQueue)
|
||
}
|
||
callID := call.ID
|
||
cctx = tool.WithProgress(cctx, func(chunk string) {
|
||
a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: secrets.RedactToolOutput(chunk)}})
|
||
})
|
||
var result string
|
||
var images []string
|
||
var err error
|
||
// When a proxy resolved a concrete target, execute that target (not the
|
||
// proxy again) so permission-approved args and evidence stay aligned.
|
||
runTool := execTool
|
||
runArgs := execArgs
|
||
if resolved.Target != nil {
|
||
runTool = resolved.Target
|
||
runArgs = resolved.Args
|
||
if len(runArgs) == 0 {
|
||
runArgs = json.RawMessage(`{}`)
|
||
}
|
||
}
|
||
if it, ok := runTool.(tool.ImageTool); ok {
|
||
result, images, err = it.ExecuteWithImages(cctx, runArgs)
|
||
} else {
|
||
result, err = runTool.Execute(cctx, runArgs)
|
||
}
|
||
result = secrets.RedactToolOutput(result)
|
||
if a.evidence != nil {
|
||
// Always record the model-visible call for audit, then the real target
|
||
// attributes for mutation/read classification when they differ.
|
||
if call.Name == "complete_step" {
|
||
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly())
|
||
a.evidence.Record(rec)
|
||
if err == nil {
|
||
a.advanceCanonicalTodo(rec.Step)
|
||
}
|
||
} else if evidenceName != call.Name {
|
||
// Proxy: meta receipt (non-mutation) + real target receipt.
|
||
a.evidence.Record(evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, true))
|
||
rec := evidence.ReceiptFromToolCall(evidenceName, evidenceArgs, err == nil, readOnly)
|
||
rec.OutputBytes = len(strings.TrimSpace(result))
|
||
a.evidence.Record(rec)
|
||
} else {
|
||
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly())
|
||
rec.OutputBytes = len(strings.TrimSpace(result))
|
||
a.evidence.Record(rec)
|
||
if err == nil && call.Name == "todo_write" {
|
||
a.setTodoState(rec.Todos)
|
||
if len(rec.Todos) > 0 {
|
||
a.deliveryCriteriaEstablished = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Track skill/capability outcomes for Delivery gates.
|
||
a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), err)
|
||
// PostToolUse hooks observe the result (they can't block); fired whether the
|
||
// call succeeded or errored, since the tool did run. Use real target name.
|
||
if a.hooks != nil {
|
||
a.hooks.PostToolUse(ctx, permName, permArgs, result)
|
||
}
|
||
if err != nil {
|
||
detail := result
|
||
// Malformed-args failures are a transient model JSON glitch (e.g. options
|
||
// written as ["a":"b"] → "invalid character ':' after array element"). The
|
||
// args can't be safely re-parsed, but echoing the tool's schema makes the
|
||
// retry land valid instead of repeating the same broken shape.
|
||
if !json.Valid([]byte(call.Arguments)) {
|
||
detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema())
|
||
}
|
||
body, truncMsg := truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, detail))
|
||
return toolOutcome{output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "", truncMsg: truncMsg}
|
||
}
|
||
a.recordRepeatSuccess(call, t)
|
||
// A foreground `task` sub-agent just finished — its result is the final answer.
|
||
// (A backgrounded one returns a "Started…" string and stops later in a job, so
|
||
// it doesn't fire here.) SubagentStop lets a hook react to delegated work.
|
||
if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) {
|
||
a.hooks.SubagentStop(ctx, result)
|
||
}
|
||
body, truncMsg := truncateToolOutput(result)
|
||
return toolOutcome{output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg}
|
||
}
|
||
|
||
func (a *Agent) checkPlanModeMCPReadOnlyTrust(ctx context.Context, call provider.ToolCall, t tool.Tool) (bool, toolOutcome, bool) {
|
||
if a.planModeReadOnlyTrust == nil {
|
||
return false, toolOutcome{}, false
|
||
}
|
||
server, rawTool, ok := planModeMCPTrustTarget(call.Name, t)
|
||
if !ok {
|
||
return false, toolOutcome{}, false
|
||
}
|
||
req := PlanModeReadOnlyTrustRequest{
|
||
ToolName: call.Name,
|
||
ServerName: server,
|
||
RawToolName: rawTool,
|
||
Args: json.RawMessage(call.Arguments),
|
||
}
|
||
allow, reason, err := a.planModeReadOnlyTrust.CheckPlanModeReadOnlyTrust(ctx, req)
|
||
if err != nil {
|
||
return false, toolOutcome{
|
||
output: fmt.Sprintf("blocked: plan-mode read-only trust approval aborted (%v)", err),
|
||
blocked: true,
|
||
errMsg: fmt.Sprintf("blocked: %v", err),
|
||
}, true
|
||
}
|
||
if !allow {
|
||
if strings.TrimSpace(reason) == "" {
|
||
reason = "the user declined to trust this MCP read-only hint — do not retry it; continue planning with other trusted read-only tools."
|
||
}
|
||
return false, toolOutcome{
|
||
output: "blocked: " + reason,
|
||
blocked: true,
|
||
errMsg: "blocked by plan-mode MCP read-only trust",
|
||
}, true
|
||
}
|
||
return true, toolOutcome{}, true
|
||
}
|
||
|
||
func (a *Agent) checkPlanModeBashReadOnlyTrust(ctx context.Context, call provider.ToolCall, trust *planmode.ReadOnlyCommandTrust) (bool, toolOutcome, bool) {
|
||
if a.planModeReadOnlyTrust == nil || trust == nil || strings.TrimSpace(trust.Prefix) == "" {
|
||
return false, toolOutcome{}, false
|
||
}
|
||
req := PlanModeReadOnlyTrustRequest{
|
||
ToolName: PlanModeReadOnlyCommandApprovalTool,
|
||
Command: trust.Command,
|
||
Prefix: trust.Prefix,
|
||
Args: json.RawMessage(call.Arguments),
|
||
}
|
||
allow, reason, err := a.planModeReadOnlyTrust.CheckPlanModeReadOnlyTrust(ctx, req)
|
||
if err != nil {
|
||
return false, toolOutcome{
|
||
output: fmt.Sprintf("blocked: plan-mode read-only command trust approval aborted (%v)", err),
|
||
blocked: true,
|
||
errMsg: fmt.Sprintf("blocked: %v", err),
|
||
}, true
|
||
}
|
||
if !allow {
|
||
if strings.TrimSpace(reason) == "" {
|
||
reason = "the user declined to trust this bash command as read-only for plan mode — do not retry it; continue planning with other trusted read-only tools."
|
||
}
|
||
return false, toolOutcome{
|
||
output: "blocked: " + reason,
|
||
blocked: true,
|
||
errMsg: "blocked by plan-mode bash read-only trust",
|
||
}, true
|
||
}
|
||
return true, toolOutcome{}, true
|
||
}
|
||
|
||
func planModeMCPTrustTarget(toolName string, t tool.Tool) (server, rawTool string, ok bool) {
|
||
if meta, metaOK := t.(tool.MCPMetadata); metaOK {
|
||
server = strings.TrimSpace(meta.MCPServerName())
|
||
rawTool = strings.TrimSpace(meta.MCPRawToolName())
|
||
if server != "" && rawTool != "" {
|
||
return server, rawTool, true
|
||
}
|
||
}
|
||
server, rawTool, ok = tool.SplitMCPName(toolName)
|
||
return server, rawTool, ok
|
||
}
|
||
|
||
func (a *Agent) planModeBlocked(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) (blocked bool, message string) {
|
||
decision := a.planModeDecision(toolName, readOnly, untrusted, safety, args)
|
||
return decision.Blocked, decision.Message
|
||
}
|
||
|
||
func (a *Agent) planModeDecision(toolName string, readOnly, untrusted bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
|
||
return planmode.Policy{
|
||
AllowedTools: a.planModeAllowedTools,
|
||
ReadOnlyCommands: a.planModeReadOnlyCommands,
|
||
}.Decide(planmode.Call{
|
||
Name: toolName,
|
||
ReadOnly: readOnly,
|
||
Untrusted: untrusted,
|
||
Safety: safety,
|
||
Args: args,
|
||
})
|
||
}
|
||
|
||
func planModeBashBlocked(args json.RawMessage) (bool, string) {
|
||
decision := planmode.Policy{}.Decide(planmode.Call{Name: "bash", Args: args})
|
||
return decision.Blocked, decision.Message
|
||
}
|
||
|
||
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 anchor-based edit, or combine the final same-file changes in one multi_edit call. This prevents stale old_string anchors and half-deleted ranges.",
|
||
call.Name, strings.Join(rec.Paths, ", ")), true
|
||
}
|
||
|
||
func anchorBasedEditTool(name string) bool {
|
||
switch name {
|
||
case "edit_file", "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, ok := a.tools.Get(name)
|
||
return ok && 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 i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
return s[:i]
|
||
}
|
||
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 "content_filter":
|
||
return "response blocked by content filter", true
|
||
case "repetition_truncation":
|
||
return "response truncated: model repetition detected", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|