mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
1874 lines
71 KiB
Go
1874 lines
71 KiB
Go
// Package agent wires a Provider, a tool Registry, and a Session into the
|
|
// harness loop that drives a coding task to completion.
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reasonix/internal/runtime/langpref"
|
|
"reasonix/internal/runtime/usecap"
|
|
"reasonix/internal/state/sessionstore"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"reasonix/internal/base/diff"
|
|
"reasonix/internal/base/nilutil"
|
|
"reasonix/internal/contract/ablation"
|
|
"reasonix/internal/contract/agentpreset"
|
|
"reasonix/internal/contract/event"
|
|
"reasonix/internal/contract/planmode"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/contract/tool"
|
|
"reasonix/internal/ext/extension/dispatch"
|
|
"reasonix/internal/runtime/capability"
|
|
"reasonix/internal/runtime/plancontract"
|
|
"reasonix/internal/runtime/taskpolicy"
|
|
"reasonix/internal/safety/evidence"
|
|
"reasonix/internal/safety/sandbox"
|
|
"reasonix/internal/state/checkpoint"
|
|
"reasonix/internal/state/instruction"
|
|
"reasonix/internal/state/memory"
|
|
"reasonix/internal/tools/jobs"
|
|
)
|
|
|
|
// MaxToolOutputBytes caps a single tool result before it goes into the model's
|
|
// context. ~32KB is roughly 8K tokens — enough for a full file read or a busy
|
|
// grep, while preventing one accidental "read this 5 MB log" from blowing the
|
|
// window before the next compaction runs.
|
|
const MaxToolOutputBytes = 32 * 1024
|
|
|
|
const maxEmptyFinalBlocks = 3
|
|
|
|
// maxStreamRecoveries is the number of body-phase stream retries after the
|
|
// initial sampling attempt (Codex-aligned default: 1 + 5 = 6 attempts total).
|
|
const maxStreamRecoveries = 5
|
|
const maxSamplingAttempts = maxStreamRecoveries + 1
|
|
const maxExecutorHandoffNudges = 1
|
|
|
|
// Renderer redraws the assistant's final-answer text as styled output. It is
|
|
// applied only after a turn's text stream completes, so the user sees raw
|
|
// markdown stream live, then a single redraw replaces it with formatted
|
|
// output. The renderer is intentionally interface-shaped so the agent stays
|
|
// independent of the cli's markdown library choice. Consumed by TextSink.
|
|
type Renderer interface {
|
|
Render(text string) string
|
|
}
|
|
|
|
// Asker puts structured multiple-choice questions to the user and blocks for the
|
|
// answers. The agent consults it for the `ask` tool. It is interface-shaped so
|
|
// the agent stays independent of the frontend; a nil asker means no interactive
|
|
// user (headless runs), where `ask` returns an unresolved result and never an
|
|
// answer. The interactive frontends wire the controller in as the Asker.
|
|
type Asker interface {
|
|
Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)
|
|
}
|
|
|
|
// callContextKey carries the executing tool call's identity into Execute.
|
|
type callContextKey struct{}
|
|
type parentSessionContextKey struct{}
|
|
type subagentDepthContextKey struct{}
|
|
type userImagesContextKey struct{}
|
|
|
|
// callContext is the per-call context a tool can read. parentID is the call being
|
|
// executed and sink is the agent's event sink (the `task` tool uses both to nest
|
|
// a sub-agent's events under this call); asker lets the `ask` tool reach the user.
|
|
type callContext struct {
|
|
parentID string
|
|
sink event.Sink
|
|
asker Asker
|
|
planMode bool
|
|
}
|
|
|
|
// WithCallContext stamps ctx with the executing call's ID, the agent's sink, and
|
|
// the asker. executeOne sets this before every Execute; `task` reads it (via
|
|
// CallContext) to nest sub-agent events, and `ask` reads the asker to prompt.
|
|
// The plan-mode flag is mirrored onto the leaf planmode key so tools that must
|
|
// not import this package (for example internal/tools/builtin) can still read it.
|
|
func WithCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
|
|
ctx = planmode.WithActive(ctx, planMode)
|
|
return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: event.BindUsageScope(sink), asker: asker, planMode: planMode})
|
|
}
|
|
|
|
// WithToolCallContext stamps ctx as a host-initiated top-level tool call.
|
|
// Normal model-selected tools receive this context from executeOne; controller
|
|
// entry points that deliberately invoke the same tool machinery (for example a
|
|
// user typing /<subagent-skill>) use this exported wrapper so nested sub-agent
|
|
// activity still reaches the parent event stream and plan-mode policy remains
|
|
// visible to the invoked runner.
|
|
func WithToolCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
|
|
return WithCallContext(ctx, parentID, sink, asker, planMode)
|
|
}
|
|
|
|
// CallContext returns the executing call's ID, the agent's sink, and the asker,
|
|
// if the context was set by an agent's executeOne. ok is false for a plain
|
|
// context (headless tool tests, calls made outside the run loop).
|
|
func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker Asker, ok bool) {
|
|
cc, ok := ctx.Value(callContextKey{}).(callContext)
|
|
if !ok {
|
|
return "", nil, nil, false
|
|
}
|
|
return cc.parentID, cc.sink, cc.asker, true
|
|
}
|
|
|
|
// PlanModeFromContext reports whether the tool call is executing during the
|
|
// plan-first workflow. Tools may use it for phase-specific behavior, but it is
|
|
// not a permission or read-only boundary.
|
|
func PlanModeFromContext(ctx context.Context) bool {
|
|
cc, ok := ctx.Value(callContextKey{}).(callContext)
|
|
return ok && cc.planMode
|
|
}
|
|
|
|
// withAgentContext establishes the agent-owned workflow capabilities for a
|
|
// model round and for tool availability checks. Missing capabilities shadow
|
|
// inherited values so child agents cannot reach parent Goal, Jobs, or memory
|
|
// state accidentally.
|
|
func (a *Agent) withAgentContext(ctx context.Context) context.Context {
|
|
if a == nil {
|
|
return ctx
|
|
}
|
|
if a.svc.jobs != nil {
|
|
ctx = jobs.WithManager(ctx, a.svc.jobs)
|
|
} else {
|
|
ctx = jobs.WithoutManager(ctx)
|
|
}
|
|
if a.svc.memQueue != nil {
|
|
ctx = memory.WithQueue(ctx, a.svc.memQueue)
|
|
} else {
|
|
ctx = memory.WithoutQueue(ctx)
|
|
}
|
|
return planmode.WithActive(ctx, a.PlanningPhase())
|
|
}
|
|
|
|
// WithParentSession stamps the active parent session ID onto a turn context so
|
|
// persisted sub-agents can record and enforce their owning conversation.
|
|
func WithParentSession(ctx context.Context, parentSession string) context.Context {
|
|
return context.WithValue(ctx, parentSessionContextKey{}, strings.TrimSpace(parentSession))
|
|
}
|
|
|
|
// ParentSession returns the active parent session ID carried by a turn context.
|
|
func ParentSession(ctx context.Context) string {
|
|
parentSession, _ := ctx.Value(parentSessionContextKey{}).(string)
|
|
return strings.TrimSpace(parentSession)
|
|
}
|
|
|
|
// WithSubagentDepth carries the current subagent depth through nested tool calls.
|
|
// The root agent runs at depth 0; each spawned subagent increments by one.
|
|
func WithSubagentDepth(ctx context.Context, depth int) context.Context {
|
|
if depth < 0 {
|
|
depth = 0
|
|
}
|
|
return context.WithValue(ctx, subagentDepthContextKey{}, depth)
|
|
}
|
|
|
|
// SubagentDepth returns the current subagent depth carried by a turn context.
|
|
func SubagentDepth(ctx context.Context) int {
|
|
depth, _ := ctx.Value(subagentDepthContextKey{}).(int)
|
|
if depth < 0 {
|
|
return 0
|
|
}
|
|
return depth
|
|
}
|
|
|
|
// WithUserImages carries the data URLs of images the user attached to this turn,
|
|
// resolved by the controller (which owns attachments) since the agent must not
|
|
// depend on it. Run embeds them on the user message; the provider sends them only
|
|
// when the model is vision-capable.
|
|
func WithUserImages(ctx context.Context, images []string) context.Context {
|
|
return context.WithValue(ctx, userImagesContextKey{}, images)
|
|
}
|
|
|
|
func UserImages(ctx context.Context) []string {
|
|
images, _ := ctx.Value(userImagesContextKey{}).([]string)
|
|
return images
|
|
}
|
|
|
|
// Gate decides, per tool call, whether it may run. The agent consults it at
|
|
// execute time after any explicit planning-phase opt-out. It is interface-shaped so the agent
|
|
// stays independent of the permission package and of how "ask" is resolved
|
|
// (silently in headless runs, interactively in the chat TUI). A nil gate means
|
|
// no gating — every call runs, preserving behaviour for callers that don't wire
|
|
// one in. reason is fed back to the model when allow is false; a non-nil err
|
|
// (e.g. ctx cancelled awaiting approval) is treated as a block for that call.
|
|
type Gate interface {
|
|
Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
|
|
}
|
|
|
|
// ExplicitDenyGate exposes the only global permission decision that applies to
|
|
// an already-authorized MCP server. Installing or approving a server is the
|
|
// user's authorization boundary; ordinary ask/fallback posture must not add a
|
|
// second per-call prompt, while explicit deny rules remain authoritative.
|
|
type ExplicitDenyGate interface {
|
|
ExplicitlyDenies(toolName string, args json.RawMessage) bool
|
|
}
|
|
|
|
const DefaultMaxSubagentDepth = 2
|
|
|
|
// NormalizeMaxSubagentDepth applies the public config contract: values below 1
|
|
// preserve the old single-delegation boundary.
|
|
func NormalizeMaxSubagentDepth(depth int) int {
|
|
if depth < 1 {
|
|
return 1
|
|
}
|
|
return depth
|
|
}
|
|
|
|
// UnlimitedSubagentDepth is the depth a user who set max_subagent_depth to 0
|
|
// asked for: no layer count stops delegation, only cost does.
|
|
const UnlimitedSubagentDepth = 1 << 20
|
|
|
|
// SubagentDepthFromConfig reads the user's max_subagent_depth: 0 removes the
|
|
// limit, a negative value reads as the single-layer boundary.
|
|
func SubagentDepthFromConfig(depth int) int {
|
|
if depth == 0 {
|
|
return UnlimitedSubagentDepth
|
|
}
|
|
return NormalizeMaxSubagentDepth(depth)
|
|
}
|
|
|
|
// ProviderRetriesFromConfig reads the user's max_provider_retries: 0 removes
|
|
// the limit, a negative value keeps the provider default.
|
|
func ProviderRetriesFromConfig(retries int) int {
|
|
switch {
|
|
case retries == 0:
|
|
return provider.RetriesUntilCancelled
|
|
case retries < 0:
|
|
return 0
|
|
}
|
|
return retries
|
|
}
|
|
|
|
// ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
|
|
// runs before the call and may block it (block=true; message is the reason fed
|
|
// back to the model); PostToolUse runs after and only surfaces output to the
|
|
// user (it can't block). It is interface-shaped so the agent stays independent
|
|
// of the hook package — a nil hooks field disables hook firing entirely.
|
|
type ToolHooks interface {
|
|
PreToolUse(ctx context.Context, name string, args json.RawMessage) (block bool, message string)
|
|
PostToolUse(ctx context.Context, name string, args json.RawMessage, result string)
|
|
PostToolUseFailure(ctx context.Context, name string, args json.RawMessage, result string, err error)
|
|
// PostLLMCall fires after each model turn completes (streaming finishes)
|
|
// but before reasoning_content is stored. It returns the (possibly
|
|
// translated) reasoning string — the original when no hook is configured.
|
|
// HasPostLLMCall reports whether such a hook exists, so the agent keeps
|
|
// streaming reasoning live when none is wired up.
|
|
PostLLMCall(ctx context.Context, reasoning string, turn int) string
|
|
HasPostLLMCall() bool
|
|
// SubagentStart/Stop bracket a foreground `task`. PreCompact returns its hooks'
|
|
// stdout as summary guidance for the next compaction; "" when none contributes.
|
|
SubagentStart(ctx context.Context, callID string, args json.RawMessage)
|
|
SubagentStop(ctx context.Context, callID, last string, err error)
|
|
PreCompact(ctx context.Context, trigger string) string
|
|
}
|
|
|
|
// Agent drives a single task: a Provider, a tool Registry, and a Session wired
|
|
// into the main loop.
|
|
type Agent struct {
|
|
agentConfig
|
|
// svc are the collaborators this agent talks to; see services.go.
|
|
svc agentServices
|
|
// stops lets one running call be ended without ending the turn.
|
|
stops callStops
|
|
// role is what this agent is; see agent_role.go.
|
|
role agentRole
|
|
// eventSource names this agent as the producer of its own frames.
|
|
eventSource string
|
|
// sess is the state one conversation owns; SetSession restarts it. See
|
|
// sessionstate.go.
|
|
sess sessionRuntime
|
|
responseLanguage atomic.Value // string: auto|zh|en
|
|
progressWatch atomic.Pointer[ProgressWatch]
|
|
reasoningLanguage atomic.Value // string: auto|zh|en
|
|
|
|
// planRuntime is the plan lifecycle: which phase, under which authority
|
|
// epoch. Replaceable, so a controller can hand several agents the one
|
|
// lifecycle they share. Never a permission or sandbox boundary.
|
|
planRuntime atomic.Pointer[planmode.Runtime]
|
|
|
|
// mutationDependencyBarrier is how much of the rest of a provider tool batch
|
|
// an earlier failure takes with it (see barrierLevel). executeOne re-checks
|
|
// it after proxy resolution so use_capability cannot bypass it by
|
|
// advertising schema-level ReadOnly()==true. Parallel read-only segments
|
|
// never set it. Cleared at the start of each executeBatch.
|
|
mutationDependencyBarrier atomic.Int32
|
|
|
|
// recovery is who this agent is to the shared gate above.
|
|
recovery recoveryIdentity
|
|
|
|
// writeWorkspaceRoot is the workspace used to normalize parent write
|
|
// reservations when writeScheduler is set.
|
|
|
|
// steer holds mid-turn guidance admitted while the agent is running.
|
|
// Entries keep a durable inbox item ID plus a loader so full bodies are not
|
|
// retained in the agent heap beyond need. Cache miss for the next API call
|
|
// is unavoidable but limited to one call — the prefix stays stable otherwise.
|
|
steer steerInbox
|
|
|
|
// task is the state shared by every Run continuing one delivery scope: the
|
|
// receipt ledger complete_step validates citations against, the spend that
|
|
// outlives a single Run, and the guards keyed to the task rather than the
|
|
// turn. See taskstate.go.
|
|
task taskRuntime
|
|
|
|
planContract *plancontract.Plan // approved plan this turn executes, if any
|
|
|
|
// hostAdvanceSeq guarantees unique tool IDs across turns: every
|
|
// emitTodoState call increments it so the frontend always sees a fresh
|
|
// dispatch even when the same panel index is signed off in different turns.
|
|
hostAdvanceSeq atomic.Int64
|
|
|
|
// projectChecks are structured project instructions that complete_step can
|
|
// verify against same-turn bash receipts after a write-backed completion.
|
|
projectChecks []instruction.VerifyCheck
|
|
// projectSensitivePaths are the globs the project declared as needing the
|
|
// hardest review. Empty means undeclared, and the host says so rather than
|
|
// guessing sensitivity from how a path is spelled.
|
|
projectSensitivePaths []string
|
|
|
|
// deliveryProfile enables the runtime-enforced delivery contract. The stable
|
|
// profile prompt explains intent; this is host state and never enters the
|
|
// provider-cached prefix. The scope ID and checkpoint it works against live
|
|
// in task; the per-turn expectations live in turn.
|
|
// When agentPreset is set, deliveryProfile is derived for baseline Delivery
|
|
// and may be elevated per-turn by TaskPolicy (e.g. Light high-risk).
|
|
deliveryProfile bool
|
|
|
|
// agentPreset is the session role setting. Atomic so SetAgentPreset can
|
|
// update subsequent turns without rebuilding the agent.
|
|
agentPreset atomic.Value // string light|balanced|delivery
|
|
|
|
// turn is the state of the Run currently executing; beginRunTurn replaces
|
|
// it wholesale. See turnruntime.go.
|
|
turn turnRuntime
|
|
|
|
// ablation names the subsystems a benchmark arm switched off. The zero value
|
|
// is the control arm.
|
|
ablation ablation.Set
|
|
|
|
// pending is what an external caller arms before the next Run; see
|
|
// turnruntime.go.
|
|
pending pendingTurn
|
|
|
|
// capabilityLedger tracks require/prefer outcomes for this user turn only.
|
|
// Never serialized into prompts or session state.
|
|
capabilityLedger *capability.Ledger
|
|
// capabilityAudit accumulates non-persisted routing/proxy counters.
|
|
capabilityAudit *capability.Audit
|
|
// capabilityGate is the turn's gate memory across final-answer retries.
|
|
capabilityGate capabilityGateState
|
|
|
|
// subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
|
|
// caps delegation; when reached, recursive agent/skill tools are excluded.
|
|
|
|
// windowProbe holds what a provider that declares no context window has
|
|
// revealed about it by rejecting or accepting requests of a known size.
|
|
windowProbe contextWindowProbe
|
|
|
|
// Context management keeps the canonical transcript immutable and installs
|
|
// at most one provider-visible checkpoint each time compactRatio is crossed.
|
|
strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies
|
|
// activeTurnCreatedAt identifies the real/synthetic user message that began
|
|
// the currently running turn. Compaction may rewrite older history while a
|
|
// tool loop is active, but it must keep this message and everything after it
|
|
// verbatim so cancellation/crash recovery can retain completed tool pairs.
|
|
activeTurnCreatedAt atomic.Int64
|
|
}
|
|
|
|
// SetPlanMode enters or leaves the plan-first workflow. Ordinary calls still
|
|
// use Permissions/Sandbox; only explicit phase opt-outs are refused. The system
|
|
// prompt and tool schemas stay untouched, while the caller supplies the
|
|
// model-facing Marker in a user turn.
|
|
func (a *Agent) SetPlanMode(v bool) { a.plan().SetActive(v) }
|
|
|
|
// SetTools replaces the agent's tool registry. The next API call picks up the
|
|
// new tool schema; tools already cached in the provider prefix are unaffected
|
|
// until the prefix is invalidated. Safe to call between turns.
|
|
func (a *Agent) SetTools(tools *tool.Registry) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.svc.setTools(tools)
|
|
}
|
|
|
|
// SetReasoningLanguage updates the visible reasoning language preference for
|
|
// subsequent user-role messages emitted by this agent.
|
|
func (a *Agent) SetReasoningLanguage(lang string) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.reasoningLanguage.Store(langpref.NormalizeReasoningLanguage(lang))
|
|
}
|
|
|
|
// SetResponseLanguage updates the final-answer language preference for
|
|
// subsequent user-role messages emitted by this agent.
|
|
func (a *Agent) SetResponseLanguage(lang string) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.responseLanguage.Store(langpref.NormalizeResponseLanguage(lang))
|
|
}
|
|
|
|
// SetGate installs the per-call permission gate. Used by interactive CLI sessions to swap the
|
|
// headless gate built in setup for an interactive one that prompts the user;
|
|
// nil disables gating. Safe to call before the run loop starts.
|
|
func (a *Agent) SetGate(g Gate) {
|
|
if nilutil.IsNil(g) {
|
|
g = nil
|
|
}
|
|
a.svc.setGate(g)
|
|
}
|
|
|
|
// SetExtensions installs the extension dispatcher after construction. Boot
|
|
// uses it because sidecars — and therefore the dispatcher — only exist after
|
|
// snapshot assembly, which runs after the agent is built. Safe to call before
|
|
// the run loop starts; nil disables interception.
|
|
func (a *Agent) SetExtensions(d *dispatch.Dispatcher) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.svc.setExtensions(d)
|
|
}
|
|
|
|
// SetRecoveryGate installs Auto Guard. Safe to call before the run loop starts;
|
|
// nil disables its checks.
|
|
func (a *Agent) SetRecoveryGate(g RecoveryGate) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
if nilutil.IsNil(g) {
|
|
g = nil
|
|
}
|
|
a.svc.recoveryGate = g
|
|
}
|
|
|
|
// SetRecoveryIdentity sets the agent/task labels used on recovery cards.
|
|
func (a *Agent) SetRecoveryIdentity(agentID, taskID string) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.recovery.agentID = strings.TrimSpace(agentID)
|
|
a.recovery.taskID = strings.TrimSpace(taskID)
|
|
}
|
|
|
|
// RecoveryGate returns the attached Auto Guard (may be nil).
|
|
func (a *Agent) RecoveryGate() RecoveryGate {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
return a.svc.recoveryGate
|
|
}
|
|
|
|
// SetSandboxEscapeApprover installs the optional one-shot approval path used by
|
|
// the bash tool when an enforced OS sandbox fails to start.
|
|
func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
|
|
if nilutil.IsNil(g) {
|
|
g = nil
|
|
}
|
|
a.svc.sandboxEscape = g
|
|
}
|
|
|
|
// SetConfigWriteApprover installs the optional per-write approval path used by
|
|
// the file tools when a target is a Reasonix-managed config file outside the
|
|
// workspace write roots.
|
|
func (a *Agent) SetConfigWriteApprover(g tool.ConfigWriteApprover) {
|
|
if nilutil.IsNil(g) {
|
|
g = nil
|
|
}
|
|
a.svc.configWrite = g
|
|
}
|
|
|
|
// SetPaidSearchApprover installs the path that asks the user before web_search
|
|
// uses a backend the vendor charges for. Without it no paid backend is called.
|
|
func (a *Agent) SetPaidSearchApprover(g tool.PaidSearchApprover) {
|
|
if nilutil.IsNil(g) {
|
|
g = nil
|
|
}
|
|
a.svc.paidSearch = g
|
|
}
|
|
|
|
func (a *Agent) withTurnPreferences(input string) string {
|
|
if a == nil {
|
|
return input
|
|
}
|
|
responseLang := "auto"
|
|
if v := a.responseLanguage.Load(); v != nil {
|
|
if s, ok := v.(string); ok {
|
|
responseLang = s
|
|
}
|
|
}
|
|
input = langpref.WithResponseLanguage(input, responseLang)
|
|
|
|
lang := "auto"
|
|
if v := a.reasoningLanguage.Load(); v != nil {
|
|
if s, ok := v.(string); ok {
|
|
lang = s
|
|
}
|
|
}
|
|
input = langpref.WithReasoningLanguage(input, lang)
|
|
input = WithWorkspace(input, a.writeWorkspaceRoot, a.workspaceVCS, a.extraRoots)
|
|
// The frozen role setting rides the per-turn <execution-policy>, not a marker.
|
|
return input
|
|
}
|
|
|
|
// SetAsker installs the asker the `ask` tool uses to question the user.
|
|
// Interactive frontends wire one in; headless runs leave it nil.
|
|
func (a *Agent) SetAsker(as Asker) { a.svc.setAsker(as) }
|
|
|
|
// SetMemoryQueue installs the sink the remember/forget tools use to apply a
|
|
// memory change in the current session. The controller wires itself in.
|
|
func (a *Agent) SetMemoryQueue(q memory.Queue) { a.svc.memQueue = q }
|
|
|
|
// SetPreEditHook installs the pre-edit snapshot hook (see onPreEdit). The
|
|
// controller wires it to its per-session checkpoint store; nil disables capture.
|
|
// Prefer SetMutationObserver for v2 capture (before+after fingerprints).
|
|
func (a *Agent) SetPreEditHook(fn func(diff.Change)) { a.svc.preEdit = fn }
|
|
|
|
// SetMutationObserver installs the unified mutation observer. When set, it
|
|
// supersedes onPreEdit for capture and also records after-mutation fingerprints.
|
|
// When a task tool is already registered it inherits the observer for sub-agents.
|
|
func (a *Agent) SetMutationObserver(obs *checkpoint.MutationObserver) {
|
|
a.svc.mutationObserver = obs
|
|
if a.svc.tools == nil || obs == nil {
|
|
return
|
|
}
|
|
if t, ok := a.svc.tools.Get("task"); ok {
|
|
if observed, ok := t.(mutationObserverSetter); ok {
|
|
observed.SetMutationObserver(obs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MutationObserver returns the installed observer (may be nil).
|
|
func (a *Agent) MutationObserver() *checkpoint.MutationObserver {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
return a.svc.mutationObserver
|
|
}
|
|
|
|
// Session returns the agent's current conversation, useful for persistence
|
|
// hooks that need to read the message log between turns. sessMu serialises this
|
|
// pointer read against SetSession, so a frontend (serve's concurrent /history and
|
|
// /new handlers) can't race the swap. The run loop touches a.session directly and
|
|
// only swaps it via SetSession while idle, so its reads need no lock.
|
|
func (a *Agent) Session() *sessionstore.Session {
|
|
a.sess.mu.Lock()
|
|
defer a.sess.mu.Unlock()
|
|
return a.sess.conversation
|
|
}
|
|
|
|
// SetSession replaces the agent's conversation wholesale. Used by
|
|
// `reasonix --resume` to load a saved JSONL transcript before the first turn,
|
|
// so the model picks up exactly where it left off. Callers serialise it against a
|
|
// running turn (it only fires while idle); sessMu guards the pointer swap itself.
|
|
func (a *Agent) SetSession(s *sessionstore.Session) {
|
|
a.sess.reset(s)
|
|
a.replayTouchedPaths(s)
|
|
// The replaced conversation's task is over, but the ledger and the bill
|
|
// answer to beginRunTurn's scope check rather than to this seam.
|
|
if s != nil {
|
|
a.rebuildTodoState(s.Snapshot())
|
|
}
|
|
}
|
|
|
|
// LastUsage returns the most recent per-turn token telemetry the provider
|
|
// reported (nil if no turn has run yet). The TUI uses it to show a context
|
|
// gauge alongside the prompt; ContextManager.Prepare owns cache-breaking
|
|
// maintenance decisions.
|
|
func (a *Agent) LastUsage() *provider.Usage { return a.sess.output.lastUsage.Load() }
|
|
|
|
// SessionCache returns the cumulative cache hit/miss prompt tokens across every
|
|
// API call this session — the basis for the status line's aggregate hit-rate.
|
|
func (a *Agent) SessionCache() (hit, miss int) {
|
|
return int(a.sess.cacheHit.Load()), int(a.sess.cacheMiss.Load())
|
|
}
|
|
|
|
// ContextWindow returns the configured context-window size in tokens. 0
|
|
// means compaction is disabled for this agent.
|
|
func (a *Agent) ContextWindow() int { return a.window().effectiveContextWindow() }
|
|
|
|
// SetSink replaces the agent's event sink. Controllers use this to wrap the
|
|
// sink after construction (e.g. durable inbox observation) without rebuilding
|
|
// the agent.
|
|
func (a *Agent) SetSink(sink event.Sink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
if nilutil.IsNil(sink) {
|
|
sink = event.Discard
|
|
}
|
|
a.svc.sink = withProducer(sink, a.eventSource)
|
|
}
|
|
|
|
// CompactRatio returns the fraction of the window at which auto-compaction
|
|
// fires (e.g. 0.8). The status line uses it to show headroom to the next compact.
|
|
func (a *Agent) CompactRatio() float64 { return a.compactRatio }
|
|
|
|
// CompactNow runs one requested projection compaction (canonical transcript
|
|
// untouched). Asking waives the automatic threshold and how much was added since
|
|
// the last checkpoint, but a context nothing was added to is still declined
|
|
// rather than summarized a second time. Only IgnoreEconomics waives that.
|
|
func (a *Agent) CompactNow(ctx context.Context, req CompactRequest) (CompactVerdict, error) {
|
|
prepared, err := a.window().contextManager().Prepare(ctx, ContextPreparePolicy{
|
|
Trigger: CompactionTriggerManual,
|
|
Instructions: req.Instructions,
|
|
IgnoreThreshold: true,
|
|
IgnoreEconomics: req.IgnoreEconomics,
|
|
})
|
|
return prepared.Maintenance, err
|
|
}
|
|
|
|
// New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
|
|
// until the model gives a final answer, the context is cancelled, or the
|
|
// provider errors (compaction keeps the context bounded). A nil sink is replaced
|
|
// with event.Discard so the agent can always emit unconditionally.
|
|
func New(prov provider.Provider, tools *tool.Registry, session *sessionstore.Session, opts Options, sink event.Sink) *Agent {
|
|
if opts.CompactRatio <= 0 {
|
|
opts.CompactRatio = defaultCompactRatio
|
|
}
|
|
if nilutil.IsNil(sink) {
|
|
sink = event.Discard
|
|
}
|
|
if opts.EventSource == "" {
|
|
opts.EventSource = event.UsageSourceExecutor
|
|
}
|
|
sink = withProducer(sink, opts.EventSource)
|
|
gate := opts.Gate
|
|
if nilutil.IsNil(gate) {
|
|
gate = nil
|
|
}
|
|
sandboxEscapeApprover := opts.SandboxEscapeApprover
|
|
if nilutil.IsNil(sandboxEscapeApprover) {
|
|
sandboxEscapeApprover = nil
|
|
}
|
|
configWriteApprover := opts.ConfigWriteApprover
|
|
if nilutil.IsNil(configWriteApprover) {
|
|
configWriteApprover = nil
|
|
}
|
|
hooks := opts.Hooks
|
|
if nilutil.IsNil(hooks) {
|
|
hooks = nil
|
|
}
|
|
maxStepsKey := opts.MaxStepsKey
|
|
if strings.TrimSpace(maxStepsKey) == "" {
|
|
maxStepsKey = "max_steps"
|
|
}
|
|
maxSubagentDepth := opts.MaxSubagentDepth
|
|
if maxSubagentDepth == 0 {
|
|
maxSubagentDepth = DefaultMaxSubagentDepth
|
|
} else {
|
|
maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
|
|
}
|
|
subagentDepth := max(opts.SubagentDepth, 0)
|
|
reasoningByteLimit := opts.ReasoningByteLimit
|
|
if reasoningByteLimit == 0 {
|
|
reasoningByteLimit = defaultReasoningByteLimit
|
|
}
|
|
perseverationGuard, perseverationMaxRetries := resolvePerseverationGuard(opts.MaxPerseverationRetries)
|
|
a := &Agent{
|
|
svc: newAgentServices(prov, tools, sink, gate,
|
|
sandboxEscapeApprover, configWriteApprover, hooks, opts),
|
|
agentConfig: agentConfig{
|
|
maxSteps: opts.MaxSteps,
|
|
maxStepsKey: maxStepsKey,
|
|
reasoningByteLimit: reasoningByteLimit,
|
|
perseverationGuard: perseverationGuard,
|
|
perseverationMaxRetries: perseverationMaxRetries,
|
|
maxOutputTokens: opts.MaxOutputTokens,
|
|
temperature: opts.Temperature,
|
|
usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
|
|
modelRef: strings.TrimSpace(opts.ModelRef),
|
|
workspaceID: strings.TrimSpace(opts.WorkspaceID),
|
|
classifierTaskText: opts.ClassifierTaskText,
|
|
writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
|
|
observeRoot: opts.observeRoot(),
|
|
extraRoots: slices.Clone(opts.ExtraWorkspaceRoots),
|
|
workspaceScanLimit: opts.WorkspaceScanLimit,
|
|
renderRoot: strings.TrimSpace(opts.RenderRoot),
|
|
workspaceVCS: strings.TrimSpace(opts.WorkspaceVCS),
|
|
workspaceUnversioned: opts.WorkspaceUnversioned,
|
|
subagentDepth: subagentDepth,
|
|
maxSubagentDepth: maxSubagentDepth,
|
|
providerRetries: opts.ProviderRetries,
|
|
contextWindow: opts.ContextWindow,
|
|
compactRatio: opts.CompactRatio,
|
|
budgets: opts.CompactionBudgets,
|
|
archiveDir: opts.ArchiveDir,
|
|
},
|
|
sess: sessionRuntime{
|
|
conversation: session,
|
|
path: strings.TrimSpace(opts.SessionPath),
|
|
win: windowState{cacheState: CacheStateUnknown},
|
|
},
|
|
task: newTaskRuntime(opts.TaskBudget),
|
|
role: newAgentRole(opts),
|
|
eventSource: opts.EventSource,
|
|
recovery: recoveryIdentity{
|
|
agentID: strings.TrimSpace(opts.RecoveryAgentID),
|
|
taskID: strings.TrimSpace(opts.RecoveryTaskID),
|
|
},
|
|
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
|
projectSensitivePaths: append([]string(nil), opts.ProjectSensitivePaths...),
|
|
deliveryProfile: opts.DeliveryProfile || agentpreset.Normalize(opts.AgentPreset) == agentpreset.Delivery,
|
|
ablation: opts.Ablation,
|
|
capabilityLedger: opts.CapabilityLedger,
|
|
capabilityAudit: opts.CapabilityAudit,
|
|
strictAlternatingRoles: opts.StrictAlternatingRoles,
|
|
}
|
|
a.sess.output.outputBudget = outputBudgetOf(prov)
|
|
if a.sess.path != "" {
|
|
a.LoadProjectionSidecar(a.sess.path)
|
|
}
|
|
preset := strings.TrimSpace(opts.AgentPreset)
|
|
if preset == "" && opts.DeliveryProfile {
|
|
preset = string(agentpreset.Delivery)
|
|
}
|
|
a.SetAgentPreset(preset)
|
|
a.SetResponseLanguage(opts.ResponseLanguage)
|
|
a.SetReasoningLanguage(opts.ReasoningLanguage)
|
|
a.SetProgressWatch(opts.ProgressWatch)
|
|
a.SetRequestEffort(opts.RequestEffort)
|
|
return a
|
|
}
|
|
|
|
// SetAgentPreset updates the role setting used by subsequent turns. It does not
|
|
// rebuild providers, tools, or history. The in-flight turn keeps its frozen
|
|
// TaskPolicy.
|
|
func (a *Agent) SetAgentPreset(preset string) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
p := agentpreset.Normalize(preset)
|
|
a.agentPreset.Store(string(p))
|
|
// Keep baseline deliveryProfile aligned with Delivery role setting so
|
|
// legacy gates that still read the bool stay coherent until fully migrated.
|
|
switch p {
|
|
case agentpreset.Delivery:
|
|
a.deliveryProfile = true
|
|
case agentpreset.Balanced:
|
|
// A turn may still elevate; the baseline stays non-delivery.
|
|
a.deliveryProfile = false
|
|
}
|
|
}
|
|
|
|
// AgentPreset returns the current session role setting (never empty).
|
|
func (a *Agent) AgentPreset() string {
|
|
if a == nil {
|
|
return string(agentpreset.Balanced)
|
|
}
|
|
if v := a.agentPreset.Load(); v != nil {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return string(agentpreset.Normalize(s))
|
|
}
|
|
}
|
|
if a.deliveryProfile {
|
|
return string(agentpreset.Delivery)
|
|
}
|
|
return string(agentpreset.Balanced)
|
|
}
|
|
|
|
// TurnPolicy returns the frozen TaskPolicy for the active turn, if any.
|
|
func (a *Agent) TurnPolicy() (taskpolicy.TaskPolicy, bool) {
|
|
if a == nil || !a.turn.policySet {
|
|
return taskpolicy.TaskPolicy{}, false
|
|
}
|
|
return a.turn.policy, true
|
|
}
|
|
|
|
func usageSourceOrDefault(source, fallback string) string {
|
|
source = strings.TrimSpace(source)
|
|
if source != "" {
|
|
return source
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// reserveParentWrite holds write claims for the duration of a parent-agent
|
|
// write tool call. Returns a no-op release when reservation is not needed
|
|
// (subagent, read-only, no scheduler, or non-write tool).
|
|
func (a *Agent) reserveParentWrite(runTool tool.Tool, args json.RawMessage, readOnly bool, declared []string) (release func(), err error) {
|
|
noop := func() {}
|
|
if a == nil || a.svc.writeScheduler == nil || a.subagentDepth > 0 || readOnly || runTool == nil {
|
|
return noop, nil
|
|
}
|
|
if len(declared) > 0 {
|
|
return a.reserveDeclaredWrite(declared)
|
|
}
|
|
name := runTool.Name()
|
|
if !parentWriteGuardTarget(name) {
|
|
return noop, nil
|
|
}
|
|
claim, err := a.parentToolWriteReservation(runTool, args)
|
|
if err != nil {
|
|
return noop, err
|
|
}
|
|
return a.svc.writeScheduler.ReserveWrite(claim)
|
|
}
|
|
|
|
// Run appends the user input and drives the tool loop until the model returns a
|
|
// final answer, the context is cancelled, or the provider errors. maxSteps <= 0
|
|
// leaves the loop unbounded here: bounding it is the host's call, as a TaskBudget
|
|
// in hours or tokens rather than a round count, and every such budget ships off.
|
|
// Turn policy lives in beginRunTurn / runToolLoop / handleFinalResponse.
|
|
func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
|
ctx = a.compositionContext(ctx)
|
|
ctx, report := takeRunTimeSink(ctx)
|
|
runMaxSteps := a.maxSteps
|
|
runMaxStepsKey := a.maxStepsKey
|
|
runLimitHostOwned := false
|
|
if limit, ok := runStepLimitFromContext(ctx); ok {
|
|
runMaxSteps = limit.steps
|
|
runLimitHostOwned = true
|
|
if limit.key != "" {
|
|
runMaxStepsKey = limit.key
|
|
}
|
|
}
|
|
a.recovery.runSeq.Add(1)
|
|
// All role settings participate in the workspace lease for the run; the
|
|
// exclusive write lock is still acquired lazily on the first real writer.
|
|
if a.svc.workspaceLease != nil {
|
|
a.svc.workspaceLease.BeginRun()
|
|
defer a.svc.workspaceLease.EndRun()
|
|
}
|
|
turnStartedAt := time.Now()
|
|
workDurationMs := func() int64 {
|
|
if elapsed := time.Since(turnStartedAt).Milliseconds(); elapsed > 0 {
|
|
return elapsed
|
|
}
|
|
return 1
|
|
}
|
|
defer a.flushSteerQueue()
|
|
a.steer.open()
|
|
|
|
// Commit background-job evidence leases only after this turn delivers.
|
|
// wait/bash_output merge a finished background writer's receipts into the
|
|
// ledger provisionally; if the turn reaches a final answer (runErr == nil)
|
|
// the delivery gates have verified and reviewed those mutations, so the
|
|
// job's evidence can be permanently drained. A failed or cancelled turn
|
|
// leaves the lease uncommitted so the next turn re-collects it.
|
|
defer func() {
|
|
if runErr != nil || a.task.ledger == nil || a.svc.jobs == nil {
|
|
return
|
|
}
|
|
for _, lease := range a.task.ledger.BackgroundLeases() {
|
|
a.svc.jobs.CommitEvidenceForSession(lease.Session, lease.JobID)
|
|
}
|
|
}()
|
|
defer a.activeTurnCreatedAt.Store(0)
|
|
|
|
// agent.before_start: an extension may abort the run before the user turn
|
|
// is appended. The redacted reason surfaces like a normal run error.
|
|
if err := a.interceptAgentStart(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, state := a.beginRunTurn(ctx, input)
|
|
ctx = a.bindRootTokenBudget(ctx, state)
|
|
state.runMaxSteps = runMaxSteps
|
|
state.runMaxStepsKey = runMaxStepsKey
|
|
state.runLimitHostOwned = runLimitHostOwned
|
|
state.workDurationMs = workDurationMs
|
|
err := a.runToolLoop(ctx, state)
|
|
report(state.answeredMs, err)
|
|
return err
|
|
}
|
|
|
|
// DeliveryCheckpoint returns the compact Goal-scoped delivery state. It is safe
|
|
// to persist next to the Goal sidecar because it contains no raw arguments.
|
|
func (a *Agent) DeliveryCheckpoint() evidence.DeliveryCheckpoint {
|
|
return a.task.checkpoint
|
|
}
|
|
|
|
// RestoreDeliveryCheckpoint seeds a rebuilt controller before its next Goal
|
|
// run. A mismatched/empty scope is ignored conservatively.
|
|
func (a *Agent) RestoreDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint) {
|
|
checkpoint.ScopeID = strings.TrimSpace(checkpoint.ScopeID)
|
|
if checkpoint.ScopeID == "" {
|
|
return
|
|
}
|
|
a.task.checkpoint = checkpoint
|
|
a.task.scopeID = checkpoint.ScopeID
|
|
}
|
|
|
|
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
|
|
a.sess.todoMu.Lock()
|
|
a.sess.todoState = evidence.NormalizeSerialTodos(todos)
|
|
a.sess.todoMu.Unlock()
|
|
}
|
|
|
|
func (a *Agent) hasActiveCanonicalTodo() bool {
|
|
a.sess.todoMu.Lock()
|
|
defer a.sess.todoMu.Unlock()
|
|
for _, todo := range a.sess.todoState {
|
|
if canonicalTodoStatus(todo.Status) == "in_progress" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// emitTodoState emits a synthetic todo_write event so the frontend task panel
|
|
// reflects a host-advanced completion without the model re-sending the list.
|
|
// itemIndex is the 1-based position of the completed todo in the panel.
|
|
func (a *Agent) emitTodoState(todos []evidence.TodoItem, itemIndex int) {
|
|
args, err := json.Marshal(map[string]any{"todos": todos})
|
|
if err != nil {
|
|
return
|
|
}
|
|
id := fmt.Sprintf("host-advance-%d-%d", a.hostAdvanceSeq.Add(1), itemIndex)
|
|
t := event.Tool{ID: id, Name: "todo_write", Args: string(args), ReadOnly: true, Issuer: event.IssuedByHost}
|
|
a.svc.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
|
|
t.Output = "task list advanced by complete_step"
|
|
a.svc.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
|
|
}
|
|
|
|
// RebuildTodoState re-derives canonical task state from the current session
|
|
// transcript. Call after externally truncating the session (e.g. after a
|
|
// user-cancel strip) so Agent.todoState stays consistent with the messages.
|
|
func (a *Agent) RebuildTodoState() {
|
|
a.rebuildTodoState(a.Session().Snapshot())
|
|
}
|
|
|
|
// rebuildTodoState reconstructs the canonical task list from a transcript: the
|
|
// latest successful todo_write is the base, then every complete_step after it
|
|
// advances an item. Deterministic from persisted messages, so it survives a
|
|
// fresh load or a rewind (the truncated history yields the historical state).
|
|
// Empty after compaction drops the todo_write — no worse than no canonical list.
|
|
func (a *Agent) rebuildTodoState(msgs []provider.Message) {
|
|
successful := successfulToolCallIDs(msgs)
|
|
var todos []evidence.TodoItem
|
|
baseIdx := -1
|
|
for i, msg := range msgs {
|
|
for _, tc := range msg.ToolCalls {
|
|
if tc.Name != "todo_write" || !successful[tc.ID] {
|
|
continue
|
|
}
|
|
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.ExecutionArguments()), true, evidence.ToolFacts{ReadOnly: true})
|
|
// A successful empty todo_write is an explicit clear. Preserve it as the
|
|
// latest base so history reloads do not resurrect an older non-empty list.
|
|
todos = evidence.NormalizeSerialTodos(rec.Todos)
|
|
baseIdx = i
|
|
}
|
|
}
|
|
if baseIdx < 0 {
|
|
a.setTodoState(nil)
|
|
return
|
|
}
|
|
for i := baseIdx; i < len(msgs); i++ {
|
|
for _, tc := range msgs[i].ToolCalls {
|
|
if tc.Name != "complete_step" || !successful[tc.ID] {
|
|
continue
|
|
}
|
|
rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.ExecutionArguments()), true, evidence.ToolFacts{ReadOnly: true})
|
|
if m, ok := evidence.MatchStep(rec.Step, todos); ok {
|
|
evidence.AdvanceSerialTodo(todos, m.Index-1)
|
|
}
|
|
}
|
|
}
|
|
a.setTodoState(todos)
|
|
}
|
|
|
|
func successfulToolCallIDs(msgs []provider.Message) map[string]bool {
|
|
successful := map[string]bool{}
|
|
for _, msg := range msgs {
|
|
if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
|
|
continue
|
|
}
|
|
if !toolResultFailed(msg.Content) {
|
|
successful[msg.ToolCallID] = true
|
|
}
|
|
}
|
|
return successful
|
|
}
|
|
|
|
func toolResultFailed(content string) bool {
|
|
content = strings.TrimSpace(content)
|
|
return strings.HasPrefix(content, "error:") ||
|
|
strings.HasPrefix(content, "blocked:") ||
|
|
strings.HasPrefix(content, "Error:") ||
|
|
strings.HasPrefix(content, "[error")
|
|
}
|
|
|
|
// shouldNudgeExecutorHandoff reports whether an executor that finished a
|
|
// planner handoff without touching a tool owes another attempt. The caller has
|
|
// already established the hard part — no tool ran — so the only question left
|
|
// is whether this plan called for one, which the plan contract states outright.
|
|
func (a *Agent) shouldNudgeExecutorHandoff() bool {
|
|
plan := a.PlanContract()
|
|
if plan == nil {
|
|
// No contract, no basis: nudging would interrupt an answer that may be
|
|
// exactly right, on nothing but a guess about what the plan wanted.
|
|
return false
|
|
}
|
|
return planContractExpectsAction(*plan)
|
|
}
|
|
|
|
// planContractExpectsAction reports whether carrying out the plan requires
|
|
// touching the workspace: a step that names files cannot be
|
|
// delivered as prose alone.
|
|
func planContractExpectsAction(plan plancontract.Plan) bool {
|
|
for _, step := range plan.Steps {
|
|
if len(step.VerifiedFiles) > 0 || len(step.CandidateFiles) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func executorHandoffRetryMessage() string {
|
|
return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
|
|
|
|
The tool schema is still attached to this executor request. Do not invent that MCP servers or tools are unavailable; only report an unavailable tool after a real tool call or host error proves it.
|
|
|
|
Do not answer as the planner and do not ask how to trigger the executor.
|
|
Use your available tools now to carry out the task. If carrying out the planner's instructions requires a user-owned choice or review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so. If a write or command is blocked by permissions or workspace boundaries, state that specific blocker and ask for the needed approval/path.`
|
|
}
|
|
|
|
func hasVisibleFinalAnswer(text string) bool {
|
|
return strings.TrimSpace(text) != ""
|
|
}
|
|
|
|
func emptyFinalNotice() string {
|
|
return "No visible answer was produced; asking the assistant to respond again."
|
|
}
|
|
|
|
func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
|
|
finish := "unknown"
|
|
if u != nil && u.FinishReason != "" {
|
|
finish = u.FinishReason
|
|
}
|
|
return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
|
|
}
|
|
|
|
func executorHandoffNoticeText() string {
|
|
return "The assistant answered before taking action; asking it to use the required tools."
|
|
}
|
|
|
|
func toolBudgetNoticeText() string {
|
|
return "Tool round limit reached; asking the assistant to summarize progress."
|
|
}
|
|
|
|
// stream runs one completion, emitting reasoning and text deltas as typed
|
|
// events and collecting complete tool calls. A Message event closes the text
|
|
// stream so a sink can re-render the streamed raw text as styled markdown. The
|
|
// accumulated text and reasoning are also returned so the caller can round-trip
|
|
// reasoning on the next turn.
|
|
//
|
|
// When frozen is non-nil, the request is not rebuilt from session — retries
|
|
// must replay the same provider-visible body.
|
|
func (a *Agent) stream(ctx context.Context, turn int, sink event.Sink) streamedTurn {
|
|
return a.streamWithFrozen(ctx, turn, sink, nil, "")
|
|
}
|
|
|
|
func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink, frozen *samplingRequest, attemptID string) streamedTurn {
|
|
ctx = withHeaderRetries(ctx, sink, a.providerRetries)
|
|
// Reuse a parent attempt counter when present so stream retries accumulate
|
|
// into one RequestCount; otherwise install a fresh counter for this call.
|
|
ctx = provider.WithRequestAttemptCounter(ctx)
|
|
// A stream can terminate locally before the provider channel closes (for
|
|
// example when the client-side reasoning guard fires). Own a child context
|
|
// here so every return path aborts the HTTP request and releases the provider
|
|
// reader instead of leaving generation and billing running in the background.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
var req provider.Request
|
|
var err error
|
|
if frozen != nil {
|
|
req = freezeProviderRequest(frozen.req)
|
|
} else {
|
|
prepared, perr := a.prepareSamplingRequest(ctx)
|
|
if perr != nil {
|
|
return streamedTurn{err: perr}
|
|
}
|
|
req = prepared.req
|
|
}
|
|
// Host stream cancels on generation drain (OpenAI/Anthropic HTTP reads).
|
|
defer TrackPublishedHostStream(ctx, cancel)()
|
|
thought := thoughtClock{begin: time.Now()}
|
|
ch, err := a.streamProviderRequest(ctx, req)
|
|
if err != nil {
|
|
return streamedTurn{usage: provider.UsageWithRequestAttemptCount(ctx, nil), err: err}
|
|
}
|
|
|
|
// A PostLLMCall hook rewrites the whole reasoning block, so when one is wired
|
|
// up we buffer reasoning silently and emit the transformed text once after the
|
|
// stream. With no such hook the reasoning streams live, chunk by chunk, as
|
|
// before — the common case must not lose its live "thinking…" display.
|
|
transformReasoning := a.svc.hooks != nil && a.svc.hooks.HasPostLLMCall()
|
|
|
|
var text, reasoning strings.Builder
|
|
var reasoningBytes int // streamed, including what the retention limit dropped
|
|
var signature string // provider-issued proof for the reasoning (Anthropic thinking)
|
|
var reasoningID, reasoningStatus string // Responses reasoning item id/status (meta chunk)
|
|
var calls []provider.ToolCall
|
|
var responsesItems []json.RawMessage
|
|
var partialCalls []provider.ToolCall
|
|
var usage *provider.Usage
|
|
var partialToolStarted bool
|
|
var maxArgChars int
|
|
var lastArgProgress time.Time
|
|
perseveration, perseverationDetected := newPerseverationGuards(), false
|
|
// collect packages the accumulated stream state; stored is the round-tripped reasoning.
|
|
collect := func(stored string, err error) streamedTurn {
|
|
return streamedTurn{
|
|
text: text.String(), reasoning: stored, signature: signature, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs(),
|
|
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
|
calls: calls, responsesItems: responsesItems, usage: usage,
|
|
partialToolStarted: partialToolStarted, partialCalls: partialCalls,
|
|
maxArgChars: maxArgChars, err: err, perseverationDetected: perseverationDetected,
|
|
}
|
|
}
|
|
finishReasoning := func() (stored, display string) {
|
|
original := reasoning.String()
|
|
display = original
|
|
if transformReasoning && original != "" {
|
|
display = a.svc.hooks.PostLLMCall(ctx, original, turn)
|
|
if display != "" {
|
|
sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
|
|
}
|
|
}
|
|
stored = display
|
|
providerBound := signature != "" || reasoningID != "" || reasoningStatus != ""
|
|
if providerBound || provider.RequiresReasoningRoundTrip(a.svc.prov) || (len(calls) > 0 && provider.RequiresToolCallReasoning(a.svc.prov)) {
|
|
stored = original
|
|
}
|
|
return stored, display
|
|
}
|
|
for {
|
|
var chunk provider.Chunk
|
|
select {
|
|
case <-ctx.Done():
|
|
stored, _ := finishReasoning()
|
|
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "interrupted")
|
|
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
|
return collect(stored, ctx.Err())
|
|
case c, ok := <-ch:
|
|
if !ok {
|
|
if err := ctx.Err(); err != nil {
|
|
stored, _ := finishReasoning()
|
|
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "interrupted")
|
|
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
|
return collect(stored, err)
|
|
}
|
|
stored, display := finishReasoning()
|
|
// provider.response: extensions rule on the assembled terminal
|
|
// response before it is persisted. A replacement becomes the
|
|
// visible assistant turn (the user's transcript); a block fails
|
|
// the turn.
|
|
providerSignature := signature
|
|
finalText, finalReasoning, signature, calls, usage, err := a.interceptProviderResponse(
|
|
ctx, text.String(), stored, signature, calls, usage)
|
|
if err != nil {
|
|
return streamedTurn{partialToolStarted: partialToolStarted, partialCalls: partialCalls, maxArgChars: maxArgChars, err: err}
|
|
}
|
|
// Responses reasoning IDs/status and Anthropic signatures are
|
|
// provider-bound metadata. Never attach the provider's metadata
|
|
// to reasoning that an extension replaced.
|
|
if finalReasoning != stored || signature != providerSignature {
|
|
reasoningID, reasoningStatus = "", ""
|
|
}
|
|
if finalReasoning != stored {
|
|
// The extension replaced the reasoning: what is persisted
|
|
// and what the closing Message event re-renders must agree.
|
|
display = finalReasoning
|
|
}
|
|
emitAssistantMessage(sink, finalText, display, thought.ms())
|
|
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
|
// A clean terminal never reports partialToolStarted: the calls
|
|
// slice is now authoritative and the partial cards were merged.
|
|
return streamedTurn{
|
|
text: finalText, reasoning: finalReasoning, signature: signature, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs(),
|
|
reasoningID: reasoningID, reasoningStatus: reasoningStatus,
|
|
calls: calls, responsesItems: responsesItems, usage: usage,
|
|
partialCalls: partialCalls, maxArgChars: maxArgChars, perseverationDetected: perseverationDetected,
|
|
}
|
|
}
|
|
chunk = c
|
|
}
|
|
thought.observe(chunk, time.Now())
|
|
switch chunk.Type {
|
|
case provider.ChunkReasoning:
|
|
retainReasoning(&reasoning, chunk.Text, reasoningBytes, a.reasoningByteLimit)
|
|
reasoningBytes += len(chunk.Text)
|
|
if chunk.Signature != "" {
|
|
signature = chunk.Signature
|
|
}
|
|
// 元数据 chunk(空 Text):reasoning item id/status 贯通
|
|
// SSE → session → 下一轮回传(评审 #7234 第 1 点)。
|
|
if chunk.ReasoningID != "" {
|
|
reasoningID = chunk.ReasoningID
|
|
}
|
|
if chunk.ReasoningStatus != "" {
|
|
reasoningStatus = chunk.ReasoningStatus
|
|
}
|
|
if chunk.Text != "" && !transformReasoning {
|
|
sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
|
|
}
|
|
case provider.ChunkText:
|
|
text.WriteString(chunk.Text)
|
|
sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text})
|
|
case provider.ChunkToolCallStart:
|
|
partialToolStarted = true
|
|
// Surface the tool card as soon as the call begins — before its
|
|
// (possibly large) arguments finish streaming — so the user sees it
|
|
// working instead of a stall. executeBatch emits the full dispatch
|
|
// (with args) once the call completes; the frontend merges by ID.
|
|
if tc := chunk.ToolCall; tc != nil {
|
|
partialCalls = upsertPartialToolCall(partialCalls, *tc)
|
|
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
|
ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, AttemptID: attemptID, Issuer: event.IssuedByModel,
|
|
}})
|
|
}
|
|
case provider.ChunkToolCallArgsDelta:
|
|
partialToolStarted = true
|
|
// Liveness ticks while a large argument payload streams: re-emit the
|
|
// partial dispatch with the cumulative size (time-throttled) so the
|
|
// UI can show progress instead of a dead counter for the duration of
|
|
// a 30KB write_file body.
|
|
if chunk.ArgChars > maxArgChars {
|
|
maxArgChars = chunk.ArgChars
|
|
}
|
|
if tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
|
|
partialCalls = upsertPartialToolCall(partialCalls, *tc)
|
|
lastArgProgress = time.Now()
|
|
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
|
|
ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, ArgChars: chunk.ArgChars, AttemptID: attemptID, Issuer: event.IssuedByModel,
|
|
}})
|
|
}
|
|
case provider.ChunkToolCall:
|
|
partialToolStarted = true
|
|
if chunk.ToolCall != nil {
|
|
calls = append(calls, *chunk.ToolCall)
|
|
partialCalls = upsertPartialToolCall(partialCalls, *chunk.ToolCall)
|
|
if n := len(chunk.ToolCall.Arguments); n > maxArgChars {
|
|
maxArgChars = n
|
|
}
|
|
}
|
|
case provider.ChunkResponsesItem:
|
|
responsesItems = appendProviderItem(responsesItems, chunk.ResponsesItem)
|
|
case provider.ChunkProviderTool:
|
|
a.absorbProviderRunCall(&text, sink, chunk, attemptID)
|
|
case provider.ChunkUsage:
|
|
usage = chunk.Usage
|
|
a.storeLatestRequestUsage(chunk.Usage)
|
|
a.sess.cacheHit.Add(int64(chunk.Usage.CacheHitTokens))
|
|
a.sess.cacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
|
|
case provider.ChunkError:
|
|
if provider.IsStreamInterrupted(chunk.Err) {
|
|
stored, _ := finishReasoning()
|
|
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "interrupted")
|
|
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
|
st := collect(stored, chunk.Err)
|
|
st.interrupted = true
|
|
return st
|
|
}
|
|
stored, _ := finishReasoning()
|
|
if errors.Is(chunk.Err, context.Canceled) || errors.Is(chunk.Err, context.DeadlineExceeded) {
|
|
usage = bestEffortStreamUsage(usage, text.Len(), reasoningBytes, "interrupted")
|
|
}
|
|
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
|
return collect(stored, chunk.Err)
|
|
}
|
|
if cut := a.perseverationStep(perseveration, chunk, sink, &perseverationDetected, perseverationAbort{ctx: ctx, sink: sink,
|
|
finishReasoning: finishReasoning, collect: collect, reasoningBytes: reasoningBytes, thoughtMs: thought.ms(), firstTokenMs: thought.firstMs()}); cut != nil {
|
|
return *cut
|
|
}
|
|
}
|
|
}
|
|
|
|
func bestEffortStreamUsage(current *provider.Usage, textBytes, reasoningBytes int, finishReason string) *provider.Usage {
|
|
if current == nil && textBytes == 0 && reasoningBytes == 0 {
|
|
return nil
|
|
}
|
|
var usage provider.Usage
|
|
if current != nil {
|
|
usage = *current
|
|
}
|
|
if finishReason != "" {
|
|
usage.FinishReason = finishReason
|
|
}
|
|
reasoningTokens := estimateTokensFromBytes(reasoningBytes)
|
|
textTokens := estimateTokensFromBytes(textBytes)
|
|
completionTokens := reasoningTokens + textTokens
|
|
if usage.ReasoningTokens < reasoningTokens {
|
|
usage.ReasoningTokens = reasoningTokens
|
|
usage.Estimated = true
|
|
}
|
|
if usage.CompletionTokens < completionTokens {
|
|
usage.CompletionTokens = completionTokens
|
|
usage.Estimated = true
|
|
}
|
|
if minTotal := usage.PromptTokens + usage.CompletionTokens; usage.TotalTokens < minTotal {
|
|
usage.TotalTokens = minTotal
|
|
usage.Estimated = true
|
|
}
|
|
return &usage
|
|
}
|
|
|
|
func estimateTokensFromBytes(n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
tokens := n / 4
|
|
if n%4 != 0 {
|
|
tokens++
|
|
}
|
|
if tokens <= 0 {
|
|
return 1
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) []provider.ToolCall {
|
|
for i := range calls {
|
|
if call.ID != "" && calls[i].ID == call.ID {
|
|
calls[i] = call
|
|
return calls
|
|
}
|
|
}
|
|
return append(calls, call)
|
|
}
|
|
|
|
func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provider.ToolCall, pending bool, workDurationMs int64, cause provider.StreamFailureCause) {
|
|
displayCalls := make([]provider.ToolCall, 0, len(calls))
|
|
interrupted := make([]string, 0, len(calls))
|
|
seen := make(map[string]struct{}, len(calls))
|
|
for _, call := range calls {
|
|
name := strings.TrimSpace(call.Name)
|
|
key := call.ID + "\x00" + name
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
displayCalls = append(displayCalls, provider.ToolCall{ID: call.ID, Name: name})
|
|
if name != "" {
|
|
interrupted = append(interrupted, name)
|
|
}
|
|
}
|
|
a.sess.conversation.Add(provider.Message{
|
|
Role: provider.RoleTool,
|
|
Content: text,
|
|
ReasoningContent: reasoning,
|
|
ToolCalls: displayCalls,
|
|
ToolCallID: provider.LocalOnlyToolID,
|
|
Name: provider.LocalOnlyToolName,
|
|
WorkDurationMs: workDurationMs,
|
|
LocalOnly: true,
|
|
InterruptedTurn: &provider.InterruptedTurnRecovery{
|
|
Pending: pending,
|
|
StreamFailure: cause,
|
|
InterruptedTools: interrupted,
|
|
DroppedPartialText: strings.TrimSpace(text) != "",
|
|
DroppedPartialReasoning: strings.TrimSpace(reasoning) != "",
|
|
},
|
|
})
|
|
}
|
|
|
|
func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
|
|
return CaptureShape(a.systemPrompt(), schemas, a.sess.conversation.RewriteVersion())
|
|
}
|
|
|
|
func (a *Agent) systemPrompt() string {
|
|
var b strings.Builder
|
|
for _, m := range a.sess.conversation.Messages {
|
|
if m.Role != provider.RoleSystem {
|
|
continue
|
|
}
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(m.Content)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func toEventShellExecution(in *tool.ShellExecution, durationMs int64) *event.ShellExecution {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := &event.ShellExecution{
|
|
Kind: in.Kind,
|
|
Shell: in.Shell,
|
|
ShellVersion: in.ShellVersion,
|
|
Platform: in.Platform,
|
|
SupportsAndAnd: in.SupportsAndAnd,
|
|
State: in.State,
|
|
FailurePhase: in.FailurePhase,
|
|
OutputTail: in.OutputTail, Subject: in.Subject, JobID: in.JobID,
|
|
MutationRisk: in.MutationRisk,
|
|
Verification: in.Verification,
|
|
DurationMs: in.DurationMs,
|
|
}
|
|
if out.DurationMs == 0 && durationMs > 0 {
|
|
out.DurationMs = durationMs
|
|
}
|
|
if in.ExitCode != nil {
|
|
code := *in.ExitCode
|
|
out.ExitCode = &code
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toProviderToolExecution(in *tool.ShellExecution) *provider.ToolExecution {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := &provider.ToolExecution{
|
|
Kind: in.Kind,
|
|
Shell: in.Shell,
|
|
ShellVersion: in.ShellVersion,
|
|
Platform: in.Platform,
|
|
SupportsAndAnd: in.SupportsAndAnd,
|
|
State: in.State,
|
|
FailurePhase: in.FailurePhase,
|
|
OutputTail: in.OutputTail,
|
|
MutationRisk: in.MutationRisk,
|
|
Verification: in.Verification,
|
|
DurationMs: in.DurationMs,
|
|
}
|
|
if in.ExitCode != nil {
|
|
code := *in.ExitCode
|
|
out.ExitCode = &code
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *Agent) emitFullToolDispatch(ctx context.Context, c provider.ToolCall, refreshed bool) {
|
|
t, _, ambiguous := a.svc.tools.ResolveCall(c.Name)
|
|
ok := t != nil && len(ambiguous) == 0
|
|
ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.ExecutionArguments(), ArgumentNormalization: c.ArgumentNormalization, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed, Issuer: event.IssuedByModel}
|
|
ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
|
|
if ok {
|
|
ev.Profile = delegationProfile(t, json.RawMessage(c.ExecutionArguments()))
|
|
}
|
|
a.svc.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: ev})
|
|
}
|
|
|
|
// emitResolvedToolDispatch upserts the real target classification of a stable
|
|
// proxy call without changing the provider-visible Name/Args. Append-only sinks
|
|
// ignore Refreshed events; stateful frontends replace the existing card by ID.
|
|
func (a *Agent) emitResolvedToolDispatch(c provider.ToolCall, profile *event.Profile) {
|
|
if c.ResolvedReadOnly == nil {
|
|
return
|
|
}
|
|
if c.ResolvedName != "" && c.ResolvedName != c.Name {
|
|
usecap.EmitProxyAudit(a.svc.sink, tool.ResolvedCall{
|
|
DisplayName: c.Name,
|
|
TargetName: c.ResolvedName,
|
|
CapabilityID: c.CapabilityID,
|
|
})
|
|
}
|
|
a.emitRefreshedDispatch(c, profile)
|
|
}
|
|
|
|
// completedMCPConnect recognizes a synthetic cache-miss connect call whose
|
|
// background discovery finished after the provider request was serialized. The
|
|
// connect placeholder is intentionally absent once real tools replace it, but
|
|
// the already-advertised call still completed its only job and must not surface
|
|
// as an unknown tool.
|
|
func completedMCPConnect(reg *tool.Registry, name string) (string, bool) {
|
|
server, rawName, ok := tool.SplitMCPName(name)
|
|
if !ok || rawName != "connect" {
|
|
return "", false
|
|
}
|
|
prefix := tool.MCPNamePrefix + server + "__"
|
|
for _, current := range reg.Names() {
|
|
if current != name && strings.HasPrefix(current, prefix) {
|
|
return server, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// recoveryPlanTransition detects structural rewrites of an active canonical
|
|
// task list. Initial plans and progress-only status updates stay on the fast
|
|
// path; changing step identity, order, or hierarchy while work remains is a
|
|
// semantic transition for the independent Auto reviewer.
|
|
func (a *Agent) recoveryPlanTransition(toolName string, args json.RawMessage) (bool, string, string, string) {
|
|
if a == nil || toolName != "todo_write" || a.PlanningPhase() {
|
|
return false, "", "", ""
|
|
}
|
|
before := a.CanonicalTodoState()
|
|
if len(before) == 0 || len(evidence.IncompleteTodos(before)) == 0 {
|
|
return false, "", "", ""
|
|
}
|
|
after := evidence.ReceiptFromToolCall("todo_write", args, true, evidence.ToolFacts{ReadOnly: true}).Todos
|
|
if len(after) == 0 || evidence.ValidateSerialTodos(after) != nil || !evidence.PreservesCompletedTodoPositions(before, after) {
|
|
// Let todo_write report malformed or invalid state directly; an invalid
|
|
// task list is not a meaningful plan proposal for the reviewer.
|
|
return false, "", "", ""
|
|
}
|
|
if samePlanStructure(before, after) {
|
|
return false, "", "", ""
|
|
}
|
|
return true, planReviewText(before), planReviewText(after), planTransitionDiff(before, after)
|
|
}
|
|
|
|
func samePlanStructure(a, b []evidence.TodoItem) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].Level != b[i].Level || normalizePlanStep(a[i].Content) != normalizePlanStep(b[i].Content) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func normalizePlanStep(s string) string {
|
|
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
|
}
|
|
|
|
func planReviewText(todos []evidence.TodoItem) string {
|
|
var b strings.Builder
|
|
for i, todo := range todos {
|
|
indent := ""
|
|
if todo.Level == 1 {
|
|
indent = " "
|
|
}
|
|
fmt.Fprintf(&b, "%s%d. %s [%s]", indent, i+1, normalizePlanStep(todo.Content), canonicalTodoStatus(todo.Status))
|
|
if i+1 < len(todos) {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func recoveryTaskScopeID(deliveryScopeID string, runSeq uint64) string {
|
|
if scope := strings.TrimSpace(deliveryScopeID); scope != "" {
|
|
return "goal:" + scope
|
|
}
|
|
return fmt.Sprintf("turn:%d", runSeq)
|
|
}
|
|
|
|
func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.ResolvedCall) (toolOutcome, bool) {
|
|
if a == nil || !a.role.readOnlyExecution {
|
|
return toolOutcome{}, false
|
|
}
|
|
block := func(reason string) (toolOutcome, bool) {
|
|
return toolOutcome{
|
|
output: "blocked: read-only agent cannot " + reason,
|
|
blocked: true,
|
|
errMsg: "blocked by read-only execution boundary",
|
|
}, true
|
|
}
|
|
// Destructive MCP is left for the Executor; Planner must not misread this
|
|
// as missing configuration or an unavailable MCP server.
|
|
blockDestructiveForExecutor := func(name string) (toolOutcome, bool) {
|
|
msg := "blocked: MCP capability " + name + " is destructive and is reserved for the Executor. Write the required operation into the plan/handoff so the Coordinator can hand it to the Executor; do not treat this as missing MCP configuration or an unavailable capability."
|
|
return toolOutcome{
|
|
output: msg,
|
|
blocked: true,
|
|
errMsg: "blocked: destructive MCP reserved for executor",
|
|
}, true
|
|
}
|
|
if resolved == nil {
|
|
if a.role.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
|
|
if !tool.IsMCPServerAuthorized(visible) {
|
|
return block("execute an MCP capability from an unauthorized server")
|
|
}
|
|
if readOnlyExecutionMCPDestructive(visible) {
|
|
return blockDestructiveForExecutor(visible.Name())
|
|
}
|
|
return toolOutcome{}, false
|
|
}
|
|
if visible == nil || !visible.ReadOnly() {
|
|
if reasoner, ok := visible.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
|
|
return block(reasoner.ReadOnlyExecutionBlockReason())
|
|
}
|
|
return block("execute a state-changing tool")
|
|
}
|
|
if isInstalledMCPTool(visible) && !tool.IsMCPServerAuthorized(visible) {
|
|
return block("execute a reader from an unauthorized MCP server")
|
|
}
|
|
if readOnlyExecutionMCPDestructive(visible) {
|
|
return block("execute a destructive MCP capability")
|
|
}
|
|
if h, ok := visible.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(visible) {
|
|
return block("start or mutate a host capability")
|
|
}
|
|
return toolOutcome{}, false
|
|
}
|
|
|
|
if usecap.IsCatalogReadAction(resolved.ProxyAction) {
|
|
if !isHostResolvedCatalogRead(resolved) {
|
|
return block("execute a malformed dynamic inspection")
|
|
}
|
|
return toolOutcome{}, false
|
|
}
|
|
switch resolved.ProxyAction {
|
|
case usecap.ActionDecline:
|
|
return block("decline a capability decision")
|
|
case usecap.ActionCall:
|
|
if resolved.Target == nil {
|
|
if a.role.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
|
|
if _, ok := usecap.ParseMCPServerCapabilityID(resolved.CapabilityID); ok {
|
|
return toolOutcome{}, false
|
|
}
|
|
}
|
|
return block("execute an unresolved dynamic capability")
|
|
}
|
|
if a.role.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
|
|
if isMCPLifecycleConnectTarget(resolved.Target) {
|
|
if !plannerMCPConnectAllowed(resolved.Target) {
|
|
return block("start an unauthorized MCP server")
|
|
}
|
|
} else if !tool.IsMCPServerAuthorized(resolved.Target) {
|
|
return block("execute an MCP capability from an unauthorized server")
|
|
}
|
|
if readOnlyExecutionMCPDestructive(resolved.Target) {
|
|
name := resolved.TargetName
|
|
if name == "" {
|
|
name = resolved.CapabilityID
|
|
}
|
|
return blockDestructiveForExecutor(name)
|
|
}
|
|
return toolOutcome{}, false
|
|
}
|
|
if !resolved.ReadOnly {
|
|
if reasoner, ok := resolved.Target.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
|
|
return block(reasoner.ReadOnlyExecutionBlockReason())
|
|
}
|
|
return block("execute a state-changing dynamic capability")
|
|
}
|
|
if isInstalledMCPTool(resolved.Target) && !tool.IsMCPServerAuthorized(resolved.Target) {
|
|
return block("execute a dynamic reader from an unauthorized MCP server")
|
|
}
|
|
if readOnlyExecutionMCPDestructive(resolved.Target) {
|
|
return block("execute a destructive MCP capability")
|
|
}
|
|
if h, ok := resolved.Target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(resolved.Target) {
|
|
return block("start or mutate a host capability")
|
|
}
|
|
return toolOutcome{}, false
|
|
default:
|
|
return block(fmt.Sprintf("execute dynamic capability action %q; allowed: %s, %s", resolved.ProxyAction, strings.Join(usecap.CatalogReadActions, ", "), usecap.ActionCall+" on a read-only target"))
|
|
}
|
|
}
|
|
|
|
// isHostResolvedCatalogRead is the structural shape of a catalog read: answered
|
|
// by the host, no target, no deferred state transition (decline has a Commit).
|
|
func isHostResolvedCatalogRead(rc *tool.ResolvedCall) bool {
|
|
return rc.SkipExecute && rc.Target == nil && rc.ReadOnly && rc.Commit == nil
|
|
}
|
|
|
|
func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
|
|
return tool.HasMCPDestructiveHint(t)
|
|
}
|
|
|
|
func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
|
|
if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
|
|
return false
|
|
}
|
|
if !tool.IsMCPServerAuthorized(t) {
|
|
return false
|
|
}
|
|
meta, ok := t.(tool.MCPMetadata)
|
|
if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// plannerAllowsMCPTarget reports whether a resolved use_capability target is an
|
|
// MCP tool or lifecycle connect that Planner may consider under
|
|
// PlannerMCPExecution (authorization and destructive checks run separately).
|
|
func plannerAllowsMCPTarget(t tool.Tool, targetName string) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
if isInstalledMCPTool(t) || isMCPLifecycleConnectTarget(t) {
|
|
return true
|
|
}
|
|
return isMCPExecutionTarget(t, targetName)
|
|
}
|
|
|
|
// isMCPLifecycleConnectTarget identifies on-demand MCP connect-and-list targets
|
|
// (mcp_connect__<server>) used by use_capability action=call on mcp-server ids.
|
|
func isMCPLifecycleConnectTarget(t tool.Tool) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
if _, ok := t.(mcpLifecycleConnect); ok {
|
|
return true
|
|
}
|
|
name := strings.TrimSpace(t.Name())
|
|
return strings.HasPrefix(name, "mcp_connect__")
|
|
}
|
|
|
|
// mcpLifecycleConnect is implemented by deferred connect targets so Planner
|
|
// can authorize lifecycle actions without relying on name prefixes alone.
|
|
type mcpLifecycleConnect interface {
|
|
MCPLifecycleConnect() bool
|
|
MCPServerAuthorized() bool
|
|
}
|
|
|
|
func plannerMCPConnectAllowed(t tool.Tool) bool {
|
|
if life, ok := t.(mcpLifecycleConnect); ok {
|
|
return life.MCPServerAuthorized()
|
|
}
|
|
return tool.IsMCPServerAuthorized(t)
|
|
}
|
|
|
|
func isInstalledMCPTool(t tool.Tool) bool {
|
|
meta, ok := t.(tool.MCPMetadata)
|
|
return ok && strings.TrimSpace(meta.MCPServerName()) != "" && strings.TrimSpace(meta.MCPRawToolName()) != ""
|
|
}
|
|
|
|
func isMCPExecutionTarget(t tool.Tool, name string) bool {
|
|
return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
|
|
}
|
|
|
|
func (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
|
|
if a.task.ledger == nil || !anchorBasedEditTool(call.Name) {
|
|
return "", false
|
|
}
|
|
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.ExecutionArguments()), true, a.toolFactsFor(call.Name))
|
|
if len(rec.Paths) == 0 {
|
|
return "", false
|
|
}
|
|
writeIndex, ok := a.task.ledger.LatestSuccessfulWriteIndex(rec.Paths)
|
|
if !ok || a.task.ledger.HasSuccessfulAnchorRefreshReadAfter(rec.Paths, writeIndex) {
|
|
return "", false
|
|
}
|
|
return fmt.Sprintf(
|
|
"blocked: [fresh read required] %q targets %s, which was already modified earlier this turn. Re-read the current file with read_file without offset/limit before another range deletion, or use multi_edit with exact replacements when possible. This prevents stale start/end anchors from selecting an unintended destructive span.",
|
|
call.Name, strings.Join(rec.Paths, ", ")), true
|
|
}
|
|
|
|
func anchorBasedEditTool(name string) bool {
|
|
switch name {
|
|
// edit_file synchronously reads the current file, requires a unique exact
|
|
// or narrowly fuzzy match, and returns the actual applied diff. Let it try
|
|
// optimistically; a stale old_string fails without writing and tells the
|
|
// model to re-read. delete_range remains guarded because two independently
|
|
// resolved anchors can otherwise select an unintended destructive span.
|
|
case "delete_range":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isBackgroundTaskCall(args string) bool {
|
|
var p struct {
|
|
RunInBackground bool `json:"run_in_background"`
|
|
}
|
|
_ = json.Unmarshal([]byte(args), &p)
|
|
return p.RunInBackground
|
|
}
|
|
|
|
// toolReadOnly reports a tool's ReadOnly classification by name (false for an
|
|
// unknown tool), for stamping early ToolDispatch events.
|
|
func (a *Agent) toolReadOnly(name string) bool {
|
|
t, _, ambiguous := a.svc.tools.ResolveCall(name)
|
|
return t != nil && len(ambiguous) == 0 && t.ReadOnly()
|
|
}
|
|
|
|
// firstLine returns s up to its first newline — a one-line failure summary for
|
|
// the display Err, while the full error stays in the model-facing output.
|
|
func firstLine(s string) string {
|
|
if before, _, ok := strings.Cut(s, "\n"); ok {
|
|
return before
|
|
}
|
|
return s
|
|
}
|
|
|
|
// truncateToolOutput is the first-visible hard cap for a tool result. Under-cap
|
|
// bodies are returned byte-identical. Over-cap bodies keep a tool-aware head and
|
|
// tail under MaxToolOutputBytes; the full original is stored separately as
|
|
// RawContent by the session writer. The bounded form is stable for the message
|
|
// lifetime and is never re-truncated by later maintenance.
|
|
func truncateToolOutput(s string) (string, string) {
|
|
return truncateToolOutputFor(s, "", "", MaxToolOutputBytes, defaultSideEffectingSnip)
|
|
}
|
|
|
|
// truncateToolOutputFor is the first-visible limiter. The geometry comes from
|
|
// the caller, which reads it off the tool's own SnipHint contract; toolName and
|
|
// toolCallID only populate the marker so the model can re-fetch.
|
|
func truncateToolOutputFor(s, toolName, toolCallID string, cap int, strategy snipStrategy) (string, string) {
|
|
if cap <= 0 {
|
|
cap = MaxToolOutputBytes
|
|
}
|
|
if len(s) <= cap {
|
|
return s, ""
|
|
}
|
|
headKeep := strategy.headChars
|
|
tailKeep := strategy.tailChars
|
|
if headKeep+tailKeep > cap-512 {
|
|
headKeep = cap * 2 / 3
|
|
tailKeep = cap - headKeep - 512
|
|
}
|
|
if headKeep < 1024 {
|
|
headKeep = cap / 2
|
|
tailKeep = cap / 2
|
|
}
|
|
head := snapToRuneBoundary(s, 0, headKeep)
|
|
tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s))
|
|
omitted := len(s) - len(head) - len(tail)
|
|
namePart := toolName
|
|
if namePart == "" {
|
|
namePart = "tool"
|
|
}
|
|
idPart := toolCallID
|
|
if idPart == "" {
|
|
idPart = "-"
|
|
}
|
|
notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s))
|
|
marker := fmt.Sprintf(
|
|
"\n\n…[truncated tool=%s call_id=%s original_bytes=%d kept_bytes=%d — full original retained in canonical transcript; re-read or retry with narrower args]…\n\n",
|
|
namePart, idPart, len(s), len(head)+len(tail),
|
|
)
|
|
body := head + marker + tail
|
|
if len(body) > cap {
|
|
overflow := len(body) - cap
|
|
trimHead := overflow / 2
|
|
trimTail := overflow - trimHead
|
|
if trimHead < len(head) {
|
|
head = snapToRuneBoundary(head, 0, len(head)-trimHead)
|
|
}
|
|
if trimTail < len(tail) {
|
|
tail = snapToRuneBoundary(tail, trimTail, len(tail))
|
|
}
|
|
body = head + marker + tail
|
|
}
|
|
return body, notice
|
|
}
|
|
|
|
// snapToRuneBoundary returns s[lo:hi] with the bounds nudged outward until
|
|
// both land on rune-start positions.
|
|
func snapToRuneBoundary(s string, lo, hi int) string {
|
|
for lo > 0 && !utf8.RuneStart(s[lo]) {
|
|
lo--
|
|
}
|
|
for hi < len(s) && !utf8.RuneStart(s[hi]) {
|
|
hi++
|
|
}
|
|
return s[lo:hi]
|
|
}
|
|
|
|
// finishReasonMessage maps an abnormal finish_reason to a one-line warning,
|
|
// returning ok=false for the normal terminations ("stop", "tool_calls") and a
|
|
// nil usage. The sink renders the message; the "! " prefix is presentation.
|
|
func finishReasonMessage(u *provider.Usage) (string, bool) {
|
|
if u == nil {
|
|
return "", false
|
|
}
|
|
switch u.FinishReason {
|
|
case "length":
|
|
return "response truncated: hit max output tokens", true
|
|
case "content_filter":
|
|
return "response blocked by content filter", true
|
|
case "repetition_truncation":
|
|
return "response truncated: model repetition detected", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|