mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)
Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.
1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
duplicate). Recovers any stall that slips past the transport timeout.
Local only — not pushed.
* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout
1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
'invalid character {' after top-level value' (~9 failed tool calls/session).
New recoverableToolArguments returns the first JSON value ONLY when the payload is
one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
(executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
SAME helper so the replayed transcript records the recovered first object (not {}),
keeping dispatch and history consistent. Truly-malformed JSON still hits the
existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
re-issues on a later turn).
2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
(ollama *:cloud) that withholds its 200 header until first token isn't wrongly
aborted; still bounds a truly dead reused connection.
Local only — not pushed.
* feat(local): add /turns command + raise default maxTurns 30->50
Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.
- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
/selfcorrect. Empty/status shows the current budget; a positive int sets
agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
for long tasks (e.g. /turns 150 for a full acceptance run).
Local only — not pushed.
* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget
Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.
Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.
Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS
1) A model that invents a specialist name (validator-runner, file-creator,
file-writer — none registered) got an opaque 'specialist not found' and kept
spawning doomed sub-agents. loadManifest now lists the available specialists and
notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
model self-corrects to a capable specialist. New availableSpecialistList helper.
2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
/turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
referenced by the TUI /turns clamp).
Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): surface sub-agent kill signal instead of opaque exit -1
When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.
* address CodeRabbit review on #349
- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
callbacks), so a retry never re-streams on top of output already forwarded earlier
in the turn (e.g. across the reactive-compaction retry). New test covers a
reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
the initial stream (image-rejection wrapping / context-limit compaction) via a
shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
unknown-specialist error; rely on the dynamically-rendered available list (a
custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
2710 lines
106 KiB
Go
2710 lines
106 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/streamjson"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
|
|
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
|
|
|
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
|
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
|
// the no-output case is retried (a partial turn would duplicate), so this is a
|
|
// safe recovery for a stalled/dead pooled connection.
|
|
const maxStreamStallRetries = 2
|
|
|
|
const (
|
|
toolResultMetaControl = "control"
|
|
toolResultControlSpecReview = "spec_review_required"
|
|
)
|
|
|
|
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
|
|
|
// isImageRejectionError reports whether err is a provider 400 that rejects
|
|
// image/multimodal content. This is checked BEFORE the compaction-retry path
|
|
// so an image-rejection 400 doesn't loop endlessly (the user hit this with
|
|
// minimax-m3: text-only model, image attached, 400 loop until the process
|
|
// was killed).
|
|
func isImageRejectionError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
if !strings.Contains(msg, "400") {
|
|
return false
|
|
}
|
|
for _, keyword := range []string{"image", "vision", "multimodal", "unsupported content type", "does not support"} {
|
|
if strings.Contains(msg, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type permissionRunState struct {
|
|
mu sync.Mutex
|
|
cleanups []func()
|
|
}
|
|
|
|
func (state *permissionRunState) add(cleanup func()) {
|
|
if state == nil || cleanup == nil {
|
|
return
|
|
}
|
|
state.mu.Lock()
|
|
state.cleanups = append(state.cleanups, cleanup)
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
func (state *permissionRunState) cleanup() {
|
|
if state == nil {
|
|
return
|
|
}
|
|
state.mu.Lock()
|
|
cleanups := state.cleanups
|
|
state.cleanups = nil
|
|
state.mu.Unlock()
|
|
for i := len(cleanups) - 1; i >= 0; i-- {
|
|
cleanups[i]()
|
|
}
|
|
}
|
|
|
|
// abortedToolResultNotice is the placeholder tool result recorded for a tool
|
|
// call that was advertised by the assistant turn but never executed because the
|
|
// repeated-failure guard halted the run first. It keeps every tool_use paired
|
|
// with a tool_result so the transcript stays valid for a strict provider replay.
|
|
const abortedToolResultNotice = "aborted: run halted by the repeated-failure guard"
|
|
|
|
// droppedToolCallNotice tells the model a tool call it attempted was malformed
|
|
// (missing a tool name) and dropped before execution, so it re-issues a valid
|
|
// call instead of assuming the call ran. It is surfaced both when a turn yields
|
|
// ONLY a dropped call and when a turn mixes valid calls with a dropped one.
|
|
const droppedToolCallNotice = "Your previous tool call was malformed (it was missing a tool name) and was not executed. " +
|
|
"Re-issue the tool call with a valid tool name and JSON arguments, or reply with your final answer."
|
|
|
|
// escalationFailedNoticePrefix introduces the brief, user-role note recorded
|
|
// when a requested mid-run model switch could not be performed (the
|
|
// ModelSwitcher returned an error). The run continues on the current model;
|
|
// escalation is best-effort, never fatal.
|
|
const escalationFailedNoticePrefix = "Note: could not switch to the requested model"
|
|
|
|
// The system prompt (core coding-craft instructions + workspace context + safety
|
|
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
|
|
|
|
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
|
|
if provider == nil {
|
|
return Result{}, errors.New("agent provider is required")
|
|
}
|
|
|
|
maxTurns := options.MaxTurns
|
|
if maxTurns <= 0 {
|
|
maxTurns = 12
|
|
}
|
|
|
|
registry := options.Registry
|
|
if registry == nil {
|
|
registry = tools.NewRegistry()
|
|
}
|
|
|
|
permissionMode := options.PermissionMode
|
|
if permissionMode == "" {
|
|
permissionMode = PermissionModeAuto
|
|
}
|
|
runPermissions := &permissionRunState{}
|
|
options.runPermissions = runPermissions
|
|
defer runPermissions.cleanup()
|
|
|
|
messages := zeroruntime.SeedMessagesWithImages(buildSystemPrompt(options), prompt, options.Images)
|
|
|
|
guards := newGuardState()
|
|
compactor := newCompactionState(options)
|
|
|
|
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
|
// during THIS run. It is consulted by partitionTools each turn to expose a
|
|
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
|
loaded := map[string]bool{}
|
|
|
|
// continueNudges counts how many times the headless completion gate
|
|
// (Options.RequireCompletionSignal) has re-prompted a no-tool-call turn that
|
|
// stopped with work still unfinished. Bounded by maxContinueNudges.
|
|
continueNudges := 0
|
|
// acceptanceRequested records that the one-time task-grounded acceptance check
|
|
// has already been demanded this run, so it fires at most once.
|
|
acceptanceRequested := false
|
|
|
|
result := Result{Messages: copyMessages(messages)}
|
|
for turn := 0; turn < maxTurns; turn++ {
|
|
result.Turns = turn + 1
|
|
|
|
// Build the per-turn tool list first so proactive compaction can include
|
|
// the tool-definition tokens (they ride on every request) in its estimate.
|
|
// partitionTools depends only on registry/permissions/options/loaded, not on
|
|
// the messages, so computing it before compaction is safe.
|
|
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
|
|
|
|
// PROACTIVE compaction: if the history is approaching the model's
|
|
// context window, summarize the oldest middle before building the
|
|
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
|
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
|
|
request := zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: exposed,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
}
|
|
|
|
// Report the per-category context budget for this turn so a surface can
|
|
// show utilization. Opt-in: a no-op when OnContext is unset.
|
|
if options.OnContext != nil {
|
|
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
|
}
|
|
|
|
// A transient upstream disconnect on the initial connect is retried with
|
|
// backoff (before any content is forwarded, so no OnText is duplicated);
|
|
// a context-limit / image-rejection / cancellation is NOT retried here —
|
|
// those fall through to their own handlers below.
|
|
stream, err := streamWithReconnect(ctx, provider, request, reconnectNoticeFor(options))
|
|
if err != nil {
|
|
if isImageRejectionError(err) {
|
|
result.Messages = copyMessages(messages)
|
|
return result, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, err.Error())
|
|
}
|
|
// REACTIVE compaction: a context-limit failure on the call itself
|
|
// can be recovered by compacting once and retrying the same turn.
|
|
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, err.Error()); retried {
|
|
messages = compacted
|
|
if retryErr != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, retryErr
|
|
}
|
|
// Rebuild from the compacted messages but reuse the SAME active-mode
|
|
// partition computed for this turn: it depends
|
|
// on registry+loaded, not on the messages, so they stay valid after
|
|
// compaction. Using the bare toolDefinitions here would route through an
|
|
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
|
request = zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: exposed,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
}
|
|
// Pre-content connect after a context-limit compaction: route through the
|
|
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
|
// whole run and re-burn every token (AUDIT-L1).
|
|
stream, err = streamWithReconnect(ctx, provider, request, reconnectNoticeFor(options))
|
|
}
|
|
if err != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
|
|
// never re-streams on top of output the user already saw — even across the
|
|
// reactive-compaction retry below, which overwrites `collected`.
|
|
forwardedAnything := false
|
|
mark := func() { forwardedAnything = true }
|
|
// OnUsage is telemetry, not visible output, so it does NOT mark
|
|
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
|
|
// tokens) then stalls with no content must still be safely retried. The gate
|
|
// only guards against re-streaming output the user actually SAW.
|
|
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
|
|
if options.OnText != nil {
|
|
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
|
|
}
|
|
if options.OnReasoning != nil {
|
|
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
|
|
}
|
|
if options.OnToolCallStart != nil {
|
|
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
|
|
}
|
|
if options.OnToolCallDelta != nil {
|
|
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
|
|
}
|
|
// recoverStreamError applies the same non-stall recovery the initial stream
|
|
// gets to ANY collected error — including one from a reissued (stall-retry)
|
|
// stream: an image rejection gets the friendly wrapping, and a context limit
|
|
// gets one compaction + reactive reissue (omitting visible callbacks, since
|
|
// any pre-error output was already forwarded). It returns the possibly-updated
|
|
// collected and a non-nil stop error when the run must end now.
|
|
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
|
|
if isImageRejectionError(errors.New(collected.Error)) {
|
|
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
|
|
}
|
|
// REACTIVE compaction: the streamed error may also be a context limit
|
|
// (some providers surface it mid-stream). Compact and retry once.
|
|
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
|
|
messages = compacted
|
|
if retryErr != nil {
|
|
return collected, retryErr
|
|
}
|
|
// Reuse the SAME active-mode partition (exposed) from this turn rather
|
|
// than the bare toolDefinitions: exposed depends on registry+loaded (not
|
|
// the messages), so it stays valid after compaction.
|
|
retryRequest := zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: exposed,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
}
|
|
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
|
if retryStreamErr != nil {
|
|
return collected, retryStreamErr
|
|
}
|
|
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
|
|
OnUsage: options.OnUsage,
|
|
})
|
|
}
|
|
return collected, nil
|
|
}
|
|
|
|
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
|
|
if collected.Error != "" {
|
|
updated, stop := recoverStreamError(collected)
|
|
collected = updated
|
|
if stop != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, stop
|
|
}
|
|
}
|
|
// Check ctx first: on cancellation helpers.go sets collected.Error to
|
|
// ctx.Err().Error(), so returning errors.New(collected.Error) would lose
|
|
// the wrapped sentinel and break errors.Is(err, context.Canceled).
|
|
if ctx.Err() != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, ctx.Err()
|
|
}
|
|
// A stream idle/stall timeout with NO output forwarded yet this turn can be
|
|
// safely re-issued on a fresh connection — nothing was shown, so there's no
|
|
// duplication (this recovers the macOS stale-pooled-connection hang when it
|
|
// slips past the transport's response-header timeout). A turn that already
|
|
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
|
|
// falls through to the error return below. Capped + exponential backoff.
|
|
for attempt := 1; attempt <= maxStreamStallRetries &&
|
|
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
|
|
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
|
|
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, err
|
|
}
|
|
retryRequest := zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: exposed,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
}
|
|
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
|
if retryErr != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, retryErr
|
|
}
|
|
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
|
}
|
|
if collected.Error != "" {
|
|
// Route a reissued stream's non-stall error through the SAME recovery as
|
|
// the initial stream (image-rejection wrapping / context-limit compaction)
|
|
// rather than returning it raw.
|
|
updated, stop := recoverStreamError(collected)
|
|
collected = updated
|
|
if stop != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, stop
|
|
}
|
|
if collected.Error != "" {
|
|
result.Messages = copyMessages(messages)
|
|
return result, errors.New(collected.Error)
|
|
}
|
|
}
|
|
|
|
// Carry the turn's terminal stop reason so a final answer cut off at the
|
|
// output token cap (or by a content filter) is reported as truncated. A
|
|
// tool-call turn normalizes to "" and clears any prior reason.
|
|
result.FinishReason = collected.FinishReason
|
|
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleAssistant,
|
|
Content: collected.Text,
|
|
ToolCalls: historySafeToolCalls(collected.ToolCalls),
|
|
// Preserve thinking blocks so the next turn can replay them; providers
|
|
// that use extended thinking reject tool conversations that drop them.
|
|
Reasoning: collected.ReasoningBlocks,
|
|
})
|
|
|
|
if len(collected.ToolCalls) == 0 {
|
|
// The model intended a tool call but it was malformed and dropped.
|
|
// Tell it to retry rather than silently treating text as the answer.
|
|
// This path is handled before the no-output guard so a dropped-call
|
|
// turn is never counted as a runaway empty turn.
|
|
if collected.DroppedToolCalls > 0 {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: droppedToolCallNotice,
|
|
})
|
|
continue
|
|
}
|
|
// No-output guard: a turn with visible text is a real final answer.
|
|
// A truly-empty turn (no text, no tool calls, no dropped calls) is
|
|
// counted toward the runaway cap so we stop before burning maxTurns.
|
|
if guards.observeTurn(collected) {
|
|
result.FinalAnswer = noOutputStopAnswer(result.Turns)
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
if strings.TrimSpace(collected.Text) == "" {
|
|
// Empty-but-under-cap turn: nudge the model to make progress
|
|
// rather than treating the empty response as a final answer.
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: "Your previous response had no visible output and no tool calls. " +
|
|
"Continue the task by using a tool or reply with your final answer.",
|
|
})
|
|
continue
|
|
}
|
|
// Completion gate (headless): a turn with text but no tool call is the
|
|
// model's final answer ONLY when the work is actually done. Default off
|
|
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
|
if options.RequireCompletionSignal {
|
|
// (1) Self-report downgrade (strongest, unambiguous): the model's own
|
|
// final message admits it guessed / could not meet the objective. Checked
|
|
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
|
|
// continue-nudges) and reports the accurate reason.
|
|
if reason := selfReportedIncompletion(collected.Text); reason != "" {
|
|
result.Incomplete = true
|
|
result.IncompleteReason = reason
|
|
result.FinalAnswer = collected.Text
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
|
|
// (2) The model stopped without a tool call while work may be unfinished:
|
|
// - a continuation cue ("…Let me check the config:") is an unambiguous
|
|
// mid-step stop;
|
|
// - pending update_plan items are a WEAK, ambiguous signal (the model may
|
|
// have finished without re-marking the last step).
|
|
// Nudge to continue (bounded). After the budget: a persisted continuation
|
|
// cue finalizes INCOMPLETE; pending-plan WITHOUT a cue does NOT (that would
|
|
// false-fail a completed run with stale bookkeeping) — fall through to the
|
|
// acceptance check / success.
|
|
cue := endsWithContinuationCue(collected.Text)
|
|
planPending := guards.pendingPlanItems()
|
|
if cue || planPending {
|
|
if continueNudges < maxContinueNudges {
|
|
continueNudges++
|
|
reason := "your message ended mid-step"
|
|
if !cue {
|
|
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
|
|
}
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: continueNudge(reason),
|
|
})
|
|
continue
|
|
}
|
|
if cue {
|
|
result.Incomplete = true
|
|
result.IncompleteReason = "your message ended mid-step"
|
|
result.FinalAnswer = collected.Text
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
// pending-plan only, budget spent: trust the model's completion claim
|
|
// over stale plan bookkeeping; fall through.
|
|
}
|
|
|
|
// (3) Task-grounded acceptance: before accepting a "done" turn as success,
|
|
// require ONE acceptance check grounded in the task's stated criterion
|
|
// (only when self-correct is on). Rejects "well-formed == correct",
|
|
// "existing-tests-pass == objective met", and "result == baseline" false
|
|
// successes. Bounded to a single pass; a genuine post-check completion
|
|
// (no admission, no cue) then finalizes as success on the next turn.
|
|
if options.SelfCorrect != nil && !acceptanceRequested {
|
|
acceptanceRequested = true
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: acceptanceVerificationNudge(),
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
result.FinalAnswer = collected.Text
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
|
|
// A turn with tool calls is progress: update guard counters before
|
|
// executing so the empty-turn counter resets and plan-tracking signals
|
|
// stay current.
|
|
guards.observeTurn(collected)
|
|
|
|
failureHint := ""
|
|
// turnRequestedModel records the FIRST mid-run escalation target requested
|
|
// during this turn's tool batch. The actual provider switch happens once,
|
|
// after the batch, so every tool_result is recorded first and at most one
|
|
// switch occurs per turn.
|
|
turnRequestedModel := ""
|
|
// changedFilesThisBatch aggregates every file the turn's mutating tools
|
|
// touched, so post-edit self-correction runs ONCE over the union after the
|
|
// batch — one AfterEdit call keeps the per-run attempt budget accurate and
|
|
// avoids verifying an intermediate edit a later call in the same turn already
|
|
// superseded. Its feedback is appended after the loop so every advertised
|
|
// tool call keeps its tool_result contiguous (a user message interleaved
|
|
// between tool_results breaks strict provider replay) — same after-batch
|
|
// rationale as turnRequestedModel above.
|
|
var changedFilesThisBatch []string
|
|
for index, call := range collected.ToolCalls {
|
|
if options.OnToolCall != nil {
|
|
options.OnToolCall(call)
|
|
}
|
|
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
|
if options.OnToolResult != nil {
|
|
options.OnToolResult(toolResult)
|
|
}
|
|
// Union the deferred tools this result asked to load into the per-run
|
|
// set BEFORE any abort/stop/guard branch, so a load that coincides with
|
|
// a turn-ending result is still recorded for the next turn's partition.
|
|
for _, name := range toolResult.LoadedTools {
|
|
loaded[name] = true
|
|
}
|
|
if turnRequestedModel == "" && toolResult.RequestedModel != "" {
|
|
turnRequestedModel = toolResult.RequestedModel
|
|
}
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleTool,
|
|
Content: toolResult.Output,
|
|
ToolCallID: toolResult.ToolCallID,
|
|
})
|
|
|
|
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
|
// returns context.Canceled rather than fabricating a headless answer. Stop
|
|
// promptly with that error (or run-context cancellation), keeping messages
|
|
// valid by closing out the still-advertised calls first.
|
|
if abortErr == nil && ctx.Err() != nil {
|
|
abortErr = ctx.Err()
|
|
}
|
|
if abortErr != nil {
|
|
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
|
result.Messages = copyMessages(messages)
|
|
return result, abortErr
|
|
}
|
|
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
|
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
|
result.FinalAnswer = toolResult.Output
|
|
result.StopReason = stopReason
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
|
|
// Repeated-failure guard: if a tool keeps failing the same way, hint
|
|
// once (with its schema) then halt — so no model loops on a bad call.
|
|
// Only RETRIABLE failures (bad arguments / execution errors) drive it:
|
|
// policy refusals (disabled tool, permission denial, sandbox block)
|
|
// aren't fixed by reformatting the call, so a "match this schema" hint
|
|
// would misdirect the model toward JSON shape or blocked behavior.
|
|
retriableFailure := isRetriableToolError(toolResult)
|
|
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
|
if outcome.Stop {
|
|
// The assistant message advertised EVERY collected tool call, but
|
|
// the guard halts mid-turn so the calls after this one never run.
|
|
// Append an aborted placeholder result for each remaining call so
|
|
// every tool_use has a matching tool_result and the recorded
|
|
// messages stay valid for a strict provider replay (Anthropic
|
|
// rejects a tool_use with no answering tool_result).
|
|
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
|
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
if outcome.InjectHint && failureHint == "" {
|
|
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
|
}
|
|
|
|
// Post-edit self-correction: collect the files this successful mutating
|
|
// tool changed; verification runs once over the union after the batch.
|
|
// A read-only tool (no ChangedFiles) never contributes.
|
|
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
|
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
|
}
|
|
}
|
|
|
|
// Run post-edit self-correction once over the union of files this turn
|
|
// changed, then append any feedback after every tool_result is recorded so
|
|
// the assistant's tool_results stay contiguous (a user message between
|
|
// tool_results breaks strict provider replay). nil SelfCorrect is a no-op.
|
|
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
|
|
if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: feedback,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Mid-run model escalation: if a tool asked to switch models this turn and
|
|
// a switcher is wired, rebuild the provider on the new model for the rest
|
|
// of the run. At most one switch per turn (first request wins). A switcher
|
|
// error is NON-FATAL — record a brief note and continue on the current
|
|
// model. nil switcher ⇒ requests are ignored entirely (escalation off).
|
|
if turnRequestedModel != "" && options.ModelSwitcher != nil {
|
|
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
|
|
if switchErr != nil {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
|
|
})
|
|
} else if newProvider != nil {
|
|
// Reassign the local provider so the next turn's StreamCompletion and
|
|
// compaction use it; update options.Model so subsequent RunOptions.Model,
|
|
// context-window sizing, and usage attribution follow the new model.
|
|
provider = newProvider
|
|
options.Model = turnRequestedModel
|
|
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
|
// is fixed at run start from options.ContextWindow and is NOT updated
|
|
// here, so a switch to a model with a different window keeps compacting
|
|
// against the original budget. Fixing it needs a ModelSwitcher contract
|
|
// change (return the new window) — out of scope for this change.
|
|
}
|
|
}
|
|
|
|
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
|
// calls executed above; surface the dropped call too so it is never
|
|
// silently ignored just because the turn also did real work. This is
|
|
// independent of (and additive to) the failure-hint / plan-reminder nudges.
|
|
if collected.DroppedToolCalls > 0 {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: droppedToolCallNotice,
|
|
})
|
|
}
|
|
|
|
// A repeated-failure hint (schema + exact error) takes priority over the
|
|
// planning reminders — fixing the failing call matters more than plan
|
|
// hygiene. Both are light, one-shot, user-role nudges.
|
|
if failureHint != "" {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: failureHint,
|
|
})
|
|
} else if reminder := guards.progressReminder(); reminder != "" {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: reminder,
|
|
})
|
|
} else if reminder := guards.planReminder(result.Turns); reminder != "" {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: reminder,
|
|
})
|
|
}
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, ctx.Err()
|
|
}
|
|
// Reaching here means the loop hit the maxTurns ceiling — the agent was cut off
|
|
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
|
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
|
// not success, so a run that loops to the turn limit isn't reported as done.
|
|
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
|
result.FinalAnswer = answer
|
|
result.FinishReason = finishReason
|
|
result.Messages = copyMessages(finalMessages)
|
|
if options.RequireCompletionSignal {
|
|
result.Incomplete = true
|
|
result.IncompleteReason = "reached the max-turns limit without completing"
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
result.FinalAnswer = maxTurnsAnswer
|
|
result.Messages = copyMessages(messages)
|
|
if options.RequireCompletionSignal {
|
|
result.Incomplete = true
|
|
result.IncompleteReason = "reached the max-turns limit without a final answer"
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
|
|
finalMessages := copyMessages(messages)
|
|
finalMessages = append(finalMessages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: maxTurnsFinalAnswerPrompt,
|
|
})
|
|
// The max-turns final-answer call is a pre-content connect, often after a long
|
|
// autonomous/cron run — route it through the reconnect helper so a single
|
|
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
|
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(finalMessages),
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
}, reconnectNoticeFor(options))
|
|
if err != nil {
|
|
return "", messages, ""
|
|
}
|
|
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
|
|
OnText: options.OnText,
|
|
OnReasoning: options.OnReasoning,
|
|
OnUsage: options.OnUsage,
|
|
OnToolCallStart: options.OnToolCallStart,
|
|
OnToolCallDelta: options.OnToolCallDelta,
|
|
})
|
|
if ctx.Err() != nil || collected.Error != "" || strings.TrimSpace(collected.Text) == "" {
|
|
return "", messages, ""
|
|
}
|
|
finalMessages = append(finalMessages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleAssistant,
|
|
Content: collected.Text,
|
|
})
|
|
return collected.Text, finalMessages, collected.FinishReason
|
|
}
|
|
|
|
func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
|
if len(calls) == 0 {
|
|
return nil
|
|
}
|
|
safe := make([]ToolCall, len(calls))
|
|
for i, call := range calls {
|
|
safe[i] = call
|
|
args := strings.TrimSpace(call.Arguments)
|
|
switch {
|
|
case args == "":
|
|
safe[i].Arguments = "{}"
|
|
case json.Valid([]byte(args)):
|
|
// already a single valid JSON value — keep as-is
|
|
default:
|
|
// Recover the first object for the concatenated case so the replayed
|
|
// transcript matches what executeToolCall actually ran; otherwise (real
|
|
// corruption) fall back to an empty object.
|
|
if first, ok := recoverableToolArguments(args); ok {
|
|
safe[i].Arguments = first
|
|
} else {
|
|
safe[i].Arguments = "{}"
|
|
}
|
|
}
|
|
}
|
|
return safe
|
|
}
|
|
|
|
// toolSchemaJSON renders a tool's parameter schema as readable JSON for the
|
|
// repeated-failure corrective hint, so the model can see exactly what arguments
|
|
// the tool expects. Returns "{}" if the tool or schema is unavailable.
|
|
func toolSchemaJSON(registry *tools.Registry, name string) string {
|
|
tool, ok := registry.Get(name)
|
|
if !ok {
|
|
return "{}"
|
|
}
|
|
data, err := json.MarshalIndent(schemaToRuntimeMap(tool.Parameters()), "", " ")
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// executeToolCall runs one tool call and returns its result. The second return
|
|
// is a non-nil ABORT error only when the call demands the whole run stop (a
|
|
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
|
|
// success or tool error returns a nil abort error.
|
|
// recoverableToolArguments inspects a tool call's raw JSON arguments. It returns
|
|
// the raw text of the FIRST JSON value when the payload is one value optionally
|
|
// followed by additional WHOLE JSON values (the weak-model concatenated case, e.g.
|
|
// `{A}{B}`). ok is false when the payload is genuinely malformed: a bad/truncated
|
|
// first value, OR trailing NON-JSON garbage after a valid first value (e.g.
|
|
// `{"x":1}xyz`) — that case must still error, not be silently accepted. Sharing
|
|
// this between dispatch and replay-history keeps them consistent: only a cleanly
|
|
// recoverable first object is ever used.
|
|
func recoverableToolArguments(arguments string) (first string, ok bool) {
|
|
dec := json.NewDecoder(strings.NewReader(arguments))
|
|
var head json.RawMessage
|
|
if err := dec.Decode(&head); err != nil {
|
|
return "", false
|
|
}
|
|
// The remainder must be only whole JSON values (or nothing); any decode error
|
|
// other than EOF means trailing garbage — reject so corruption still surfaces.
|
|
for {
|
|
var rest json.RawMessage
|
|
if err := dec.Decode(&rest); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return strings.TrimSpace(string(head)), true
|
|
}
|
|
return "", false
|
|
}
|
|
}
|
|
}
|
|
|
|
// decodeToolArguments decodes a tool call's JSON arguments into v. It tolerates a
|
|
// weaker model that packs MULTIPLE concatenated top-level JSON objects into one
|
|
// arguments string (`{A}{B}`) — the cause of "invalid character '{' after top-level
|
|
// value" failures with small models like minimax-m3 — by decoding the FIRST object
|
|
// and ignoring the trailing WHOLE JSON values, so the primary intended call runs.
|
|
// A genuinely malformed payload (bad/truncated first object, or non-JSON trailing
|
|
// garbage) still returns the real parse error; a standard single-object payload
|
|
// decodes exactly as before.
|
|
func decodeToolArguments(arguments string, v any) error {
|
|
if first, ok := recoverableToolArguments(arguments); ok {
|
|
return json.Unmarshal([]byte(first), v)
|
|
}
|
|
// Not cleanly recoverable — surface the genuine parse error.
|
|
return json.Unmarshal([]byte(arguments), v)
|
|
}
|
|
|
|
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
|
args := map[string]any{}
|
|
if call.Arguments != "" {
|
|
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Failed to parse arguments for " + call.Name + ": " + err.Error(),
|
|
}, nil
|
|
}
|
|
}
|
|
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
|
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
|
// discovery tool exposed to the model rejects at dispatch time (an inescapable
|
|
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
|
|
// tool_search is still honored (only the allowlist is exempted, not the denylist).
|
|
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
|
|
if !toolSearchAllowed && !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: `Error: Tool "` + call.Name + `" is not enabled for this run.`,
|
|
DenialReason: DenialFiltered,
|
|
}, nil
|
|
}
|
|
tool, toolFound := registry.Get(call.Name)
|
|
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
|
|
DenialReason: DenialFiltered,
|
|
}, nil
|
|
}
|
|
if toolFound {
|
|
if rejecter, ok := tool.(tools.PrePermissionRejecter); ok {
|
|
if result, rejected := rejecter.RejectBeforePermission(args); rejected {
|
|
return toolResultFromPrePermissionReject(call, result), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// ask_user is intercepted in the loop (like permissions) so the question can
|
|
// be routed to an interactive front-end instead of blocking inside the tool.
|
|
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
|
if call.Name == "ask_user" {
|
|
return executeAskUser(ctx, registry, call, args, permissionMode, options)
|
|
}
|
|
if call.Name == tools.RequestPermissionsToolName {
|
|
return executeRequestPermissions(ctx, call, args, permissionMode, options)
|
|
}
|
|
|
|
permissionGranted := permissionMode == PermissionModeUnsafe
|
|
if toolFound && effectivePermission(tool, args) == tools.PermissionAllow {
|
|
permissionGranted = true
|
|
}
|
|
|
|
decisionReason := ""
|
|
var decisionAction PermissionDecisionAction
|
|
var decisionCommandPrefix []string
|
|
var permissionCleanups []func()
|
|
defer func() {
|
|
for i := len(permissionCleanups) - 1; i >= 0; i-- {
|
|
if permissionCleanups[i] != nil {
|
|
permissionCleanups[i]()
|
|
}
|
|
}
|
|
}()
|
|
if toolFound && !permissionGranted {
|
|
if grant, ok, session := matchCommandPrefix(call.Name, args, options); ok {
|
|
permissionGranted = true
|
|
if session {
|
|
decisionAction = PermissionDecisionAllowPrefix
|
|
decisionReason = "session command prefix approval matched"
|
|
} else {
|
|
decisionAction = PermissionDecisionAlwaysAllowPrefix
|
|
decisionReason = "persistent command prefix approval matched"
|
|
}
|
|
decisionCommandPrefix = grant.Prefix
|
|
}
|
|
}
|
|
|
|
var preflightDecision *sandbox.Decision
|
|
if toolFound && options.Sandbox != nil {
|
|
decision := options.Sandbox.Evaluate(ctx, sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
|
preflightDecision = &decision
|
|
}
|
|
|
|
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, args, permissionGranted, preflightDecision) {
|
|
requestEvent, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, preflightDecision)
|
|
if !ok {
|
|
requestEvent = fallbackPermissionEvent(call, tool, args, permissionMode, options)
|
|
}
|
|
request := permissionRequestFromEvent(requestEvent, args, options)
|
|
decision, err := requestPermission(ctx, request, options)
|
|
if err != nil {
|
|
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
|
}
|
|
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
|
decisionReason = strings.TrimSpace(decision.Reason)
|
|
decisionAction = decision.Action
|
|
switch decision.Action {
|
|
case PermissionDecisionAllow, PermissionDecisionAllowStrict:
|
|
permissionGranted = true
|
|
requestEvent.DecisionAction = decision.Action
|
|
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
|
|
if err != nil {
|
|
reason := "failed to grant network permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
cleanup, err = grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, options)
|
|
if err != nil {
|
|
reason := "failed to grant additional permissions: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
cleanup, err = grantFilesystemForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
|
|
if err != nil {
|
|
reason := "failed to grant filesystem permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
case PermissionDecisionAllowForSession:
|
|
permissionGranted = true
|
|
requestEvent.DecisionAction = decision.Action
|
|
if shellCommandAdditionalPermissionsRequested(args) {
|
|
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeSession, options)
|
|
if err != nil {
|
|
reason := "failed to grant session additional permissions: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
} else if networkSandboxPrompt(requestEvent) {
|
|
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeSession, options)
|
|
if err != nil {
|
|
reason := "failed to grant session network permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
} else if filesystemSandboxPrompt(requestEvent) {
|
|
cleanup, err := grantFilesystemForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeSession, options)
|
|
if err != nil {
|
|
reason := "failed to grant session filesystem permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
} else if options.Sandbox != nil {
|
|
// The current call stays allowed if recording the session grant
|
|
// fails; the user is simply prompted again for a later match.
|
|
if grant, err := persistSessionPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
|
requestEvent.GrantMatched = true
|
|
requestEvent.Grant = &grant
|
|
}
|
|
}
|
|
case PermissionDecisionAllowPrefix:
|
|
if len(request.CommandPrefix) == 0 {
|
|
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
|
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
|
}
|
|
permissionGranted = true
|
|
requestEvent.DecisionAction = decision.Action
|
|
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
|
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
|
options.Sandbox.GrantCommandPrefixForSession(call.Name, decisionCommandPrefix)
|
|
}
|
|
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
|
|
if err != nil {
|
|
reason := "failed to grant network permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
case PermissionDecisionAlwaysAllowPrefix:
|
|
if len(request.CommandPrefix) == 0 {
|
|
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
|
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
|
}
|
|
permissionGranted = true
|
|
requestEvent.DecisionAction = decision.Action
|
|
decisionCommandPrefix = append([]string(nil), request.CommandPrefix...)
|
|
if options.Sandbox != nil && len(decisionCommandPrefix) > 0 {
|
|
if grant, err := persistCommandPrefixGrant(call.Name, decisionCommandPrefix, decisionReason, options); err == nil {
|
|
decisionCommandPrefix = append([]string(nil), grant.Prefix...)
|
|
}
|
|
}
|
|
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, sandbox.PermissionGrantScopeTurn, options)
|
|
if err != nil {
|
|
reason := "failed to grant network permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
return deniedPermissionResult(call, reason, requestEvent), nil
|
|
}
|
|
permissionCleanups = append(permissionCleanups, cleanup)
|
|
case PermissionDecisionAlwaysAllow:
|
|
permissionGranted = true
|
|
requestEvent.DecisionAction = decision.Action
|
|
// "Always allow" also persists a grant so FUTURE calls skip the prompt.
|
|
// With no sandbox engine there is nowhere to persist it, and persistence
|
|
// can also fail — neither must deny what the user explicitly allowed, so
|
|
// honor the allow for THIS call regardless and just skip remembering the
|
|
// grant (the user is re-prompted next time rather than denied now). This
|
|
// call's permission event is built from the sandbox decision below
|
|
// (buildPermissionEvent reads decision.GrantMatched/Grant), so the
|
|
// persisted grant does not need to be recorded on requestEvent here.
|
|
if options.Sandbox != nil {
|
|
if grant, err := persistPermissionGrant(call.Name, args, decisionReason, options); err == nil {
|
|
requestEvent.GrantMatched = true
|
|
requestEvent.Grant = &grant
|
|
}
|
|
}
|
|
case PermissionDecisionCancel:
|
|
emitCanceledPermission(options, call, requestEvent, decisionReason)
|
|
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
|
default:
|
|
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
|
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
|
}
|
|
}
|
|
|
|
// beforeTool hooks may veto the call before it runs (a non-zero exit blocks).
|
|
if toolFound {
|
|
if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked {
|
|
return blockedByHookResult(call, outcome), nil
|
|
}
|
|
}
|
|
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
|
|
|
|
// Task tool: wire progress callback so the TUI sees live tool-call events
|
|
// from the specialist child process.
|
|
var progressCallback func(streamjson.Event)
|
|
if call.Name == "Task" && options.OnToolProgress != nil {
|
|
toolCallID := call.ID
|
|
onProgress := options.OnToolProgress
|
|
progressCallback = func(event streamjson.Event) {
|
|
onProgress(toolCallID, event)
|
|
}
|
|
}
|
|
|
|
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: string(permissionMode),
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
ToolCallID: call.ID,
|
|
SessionID: options.SessionID,
|
|
Model: options.Model,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
Depth: options.Depth,
|
|
Cwd: options.Cwd,
|
|
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
|
// a file that changed on disk outside Zero since it was last read.
|
|
FileTracker: options.FileTracker,
|
|
// Forward the run's operator tool filters so a filter-aware tool
|
|
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
|
EnabledTools: options.EnabledTools,
|
|
DisabledTools: options.DisabledTools,
|
|
Progress: progressCallback,
|
|
// The sandbox decision (if any) is returned synchronously on the Result and
|
|
// used here for permission event building.
|
|
})
|
|
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
|
|
if directResult != nil {
|
|
return *directResult, abortErr
|
|
}
|
|
result = retryResult
|
|
permissionGranted = true
|
|
decisionAction = action
|
|
decisionReason = reason
|
|
if len(prefix) > 0 {
|
|
decisionCommandPrefix = append([]string(nil), prefix...)
|
|
}
|
|
}
|
|
sandboxDecision := result.SandboxDecision
|
|
if toolFound && options.OnPermission != nil {
|
|
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
|
|
event.DecisionReason = decisionReason
|
|
if decisionAction != "" {
|
|
event.DecisionAction = decisionAction
|
|
}
|
|
if len(decisionCommandPrefix) > 0 {
|
|
event.CommandPrefix = append([]string(nil), decisionCommandPrefix...)
|
|
}
|
|
options.OnPermission(event)
|
|
}
|
|
}
|
|
// afterTool hooks run once the tool has executed; their output (e.g. a
|
|
// formatter or vet result) is surfaced back to the model on the result.
|
|
if toolFound {
|
|
if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" {
|
|
var didRedact bool
|
|
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
|
|
if didRedact {
|
|
result.Redacted = true
|
|
}
|
|
}
|
|
}
|
|
// Secret scrubbing happens at the registry boundary (the single point both
|
|
// the agent loop and the MCP server pass through), so result.Output is
|
|
// already redacted here and result.Redacted reflects whether it changed.
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: result.Status,
|
|
Output: result.Output,
|
|
Meta: result.Meta,
|
|
Redacted: result.Redacted,
|
|
ChangedFiles: result.ChangedFiles,
|
|
Display: result.Display,
|
|
LoadedTools: loadedToolsFromResult(result.Meta),
|
|
// A tool may signal a mid-run model escalation by carrying the target id
|
|
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
|
// the Run turn loop performs the actual provider switch. Empty for every
|
|
// ordinary tool result.
|
|
RequestedModel: result.Meta["escalate_to_model"],
|
|
}, nil
|
|
}
|
|
|
|
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
|
|
|
|
func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, []string, error) {
|
|
if retryResult, directResult, retried, action, reason, abortErr := maybeRetryWithNetworkAfterSandboxDenial(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
|
|
return retryResult, directResult, retried, action, reason, nil, abortErr
|
|
}
|
|
if !sandboxRestrictedShellRetryCandidate(call, args, result, options) {
|
|
return result, nil, false, "", "", nil, nil
|
|
}
|
|
requestEvent := sandboxRestrictionRetryEvent(call, tool, args, permissionMode, options, result)
|
|
request := permissionRequestFromEvent(requestEvent, args, options)
|
|
if permissionMode == PermissionModeUnsafe {
|
|
retryArgs := unsandboxedRetryArgs(args)
|
|
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
|
|
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits unsandboxed retry", nil, nil
|
|
}
|
|
decision, err := requestPermission(ctx, request, options)
|
|
if err != nil {
|
|
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
|
}
|
|
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
|
reason := strings.TrimSpace(decision.Reason)
|
|
if reason == "" {
|
|
reason = request.Reason
|
|
}
|
|
switch decision.Action {
|
|
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix:
|
|
prefix := []string(nil)
|
|
if decision.Action == PermissionDecisionAllowPrefix || decision.Action == PermissionDecisionAlwaysAllowPrefix {
|
|
if len(request.CommandPrefix) == 0 {
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
denied := deniedPermissionResult(call, reason, requestEvent)
|
|
return result, &denied, true, decision.Action, reason, nil, nil
|
|
}
|
|
prefix = append([]string(nil), request.CommandPrefix...)
|
|
if options.Sandbox != nil {
|
|
if decision.Action == PermissionDecisionAlwaysAllowPrefix {
|
|
if grant, err := persistCommandPrefixGrant(call.Name, prefix, reason, options); err == nil {
|
|
prefix = append([]string(nil), grant.Prefix...)
|
|
}
|
|
} else {
|
|
options.Sandbox.GrantCommandPrefixForSession(call.Name, prefix)
|
|
}
|
|
}
|
|
}
|
|
retryArgs := unsandboxedRetryArgs(args)
|
|
retry := runToolForUnsandboxedRetry(ctx, registry, call.Name, call.ID, retryArgs, permissionMode, options, progressCallback)
|
|
return retry, nil, true, decision.Action, reason, prefix, nil
|
|
case PermissionDecisionCancel:
|
|
emitCanceledPermission(options, call, requestEvent, reason)
|
|
canceled := canceledPermissionResult(call, reason, requestEvent)
|
|
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
|
default:
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
denied := deniedPermissionResult(call, reason, requestEvent)
|
|
return result, &denied, true, decision.Action, reason, nil, nil
|
|
}
|
|
}
|
|
|
|
func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) (tools.Result, *ToolResult, bool, PermissionDecisionAction, string, error) {
|
|
if !sandboxDeniedNetworkRetryCandidate(call, args, result, options) {
|
|
return result, nil, false, "", "", nil
|
|
}
|
|
requestEvent := sandboxDeniedNetworkRetryEvent(call, tool, args, permissionMode, options, result)
|
|
request := permissionRequestFromEvent(requestEvent, args, options)
|
|
if permissionMode == PermissionModeUnsafe {
|
|
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
|
|
return retry, nil, true, PermissionDecisionAllow, "unsafe permission mode permits sandbox network retry", nil
|
|
}
|
|
decision, err := requestPermission(ctx, request, options)
|
|
if err != nil {
|
|
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
|
}
|
|
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
|
reason := strings.TrimSpace(decision.Reason)
|
|
if reason == "" {
|
|
reason = request.Reason
|
|
}
|
|
switch decision.Action {
|
|
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession:
|
|
scope := sandbox.PermissionGrantScopeTurn
|
|
if decision.Action == PermissionDecisionAllowForSession {
|
|
scope = sandbox.PermissionGrantScopeSession
|
|
}
|
|
cleanup, err := grantNetworkForSandboxPrompt(requestEvent, scope, options)
|
|
if err != nil {
|
|
reason := "failed to grant network permission: " + err.Error()
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
denied := deniedPermissionResult(call, reason, requestEvent)
|
|
return result, &denied, true, decision.Action, reason, nil
|
|
}
|
|
if cleanup != nil {
|
|
defer cleanup()
|
|
}
|
|
retry := runToolForNetworkRetry(ctx, registry, call.Name, call.ID, args, permissionMode, options, progressCallback)
|
|
return retry, nil, true, decision.Action, reason, nil
|
|
case PermissionDecisionCancel:
|
|
emitCanceledPermission(options, call, requestEvent, reason)
|
|
canceled := canceledPermissionResult(call, reason, requestEvent)
|
|
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
|
default:
|
|
emitDeniedPermission(options, call, requestEvent, reason)
|
|
denied := deniedPermissionResult(call, reason, requestEvent)
|
|
return result, &denied, true, decision.Action, reason, nil
|
|
}
|
|
}
|
|
|
|
func sandboxDeniedNetworkRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
|
|
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
|
return false
|
|
}
|
|
if result.Meta[tools.SandboxLikelyDeniedMeta] != "true" || result.Meta[tools.SandboxDenialKindMeta] != tools.SandboxDenialKindNetwork {
|
|
return false
|
|
}
|
|
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sandboxDeniedNetworkRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
|
|
risk := sandbox.Classify(sandbox.Request{
|
|
ToolName: call.Name,
|
|
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
|
|
Permission: sandbox.Permission(tool.Safety().Permission),
|
|
PermissionGranted: false,
|
|
PermissionMode: sandbox.PermissionMode(permissionMode),
|
|
Args: args,
|
|
Reason: sandbox.ReasonNetworkBlocked,
|
|
})
|
|
risk = ensureRiskCategory(risk, "network", sandbox.RiskCritical)
|
|
reason := sandbox.ReasonNetworkBlocked
|
|
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
|
|
reason += " (" + keyword + ")"
|
|
}
|
|
return PermissionEvent{
|
|
ToolCallID: call.ID,
|
|
ToolName: call.Name,
|
|
Action: PermissionActionPrompt,
|
|
Permission: string(tools.PermissionPrompt),
|
|
PermissionMode: permissionMode,
|
|
Autonomy: options.Autonomy,
|
|
SideEffect: string(tools.SideEffectShell),
|
|
Reason: sandbox.ReasonNetworkBlocked,
|
|
DecisionReason: reason,
|
|
Scope: permissionScope(call.Name, args),
|
|
Risk: risk,
|
|
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
|
}
|
|
}
|
|
|
|
func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, result tools.Result, options Options) bool {
|
|
if options.Sandbox == nil || !isShellCommandTool(call.Name) {
|
|
return false
|
|
}
|
|
if !options.Sandbox.UnsandboxedExecutionAllowed() {
|
|
return false
|
|
}
|
|
if value, _ := args["sandbox_permissions"].(string); strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated) {
|
|
return false
|
|
}
|
|
return sandboxDeniedShellResult(result) || sandboxNamespaceLimitedShellResult(result)
|
|
}
|
|
|
|
func sandboxDeniedShellResult(result tools.Result) bool {
|
|
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
|
|
}
|
|
|
|
func sandboxNamespaceLimitedShellResult(result tools.Result) bool {
|
|
if result.Status != tools.StatusOK {
|
|
return false
|
|
}
|
|
if result.Meta["sandbox_wrapped"] != "true" {
|
|
return false
|
|
}
|
|
if result.Meta["sandbox_backend"] != string(sandbox.BackendLinuxBwrap) && result.Meta["sandbox_target_backend"] != string(sandbox.BackendLinuxBwrap) {
|
|
return false
|
|
}
|
|
output := strings.ToLower(result.Output)
|
|
return strings.Contains(output, "bwrap ") &&
|
|
strings.Contains(output, "--unshare-pid") &&
|
|
strings.Contains(output, "-- /bin/sh -c")
|
|
}
|
|
|
|
func sandboxRestrictionRetryEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options, result tools.Result) PermissionEvent {
|
|
reason := sandboxRestrictionRetryReason(result)
|
|
if keyword := strings.TrimSpace(result.Meta[tools.SandboxDenialKeywordMeta]); keyword != "" {
|
|
reason += " (" + keyword + ")"
|
|
}
|
|
risk := sandbox.Classify(sandbox.Request{
|
|
ToolName: call.Name,
|
|
SideEffect: sandbox.SideEffect(tool.Safety().SideEffect),
|
|
Permission: sandbox.Permission(tool.Safety().Permission),
|
|
PermissionGranted: false,
|
|
PermissionMode: sandbox.PermissionMode(permissionMode),
|
|
Args: args,
|
|
Reason: reason,
|
|
})
|
|
return PermissionEvent{
|
|
ToolCallID: call.ID,
|
|
ToolName: call.Name,
|
|
Action: PermissionActionPrompt,
|
|
Permission: string(tools.PermissionPrompt),
|
|
PermissionMode: permissionMode,
|
|
Autonomy: options.Autonomy,
|
|
SideEffect: string(tools.SideEffectShell),
|
|
Reason: reason,
|
|
Scope: permissionScope(call.Name, args),
|
|
Risk: risk,
|
|
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
|
}
|
|
}
|
|
|
|
func sandboxRestrictionRetryReason(result tools.Result) string {
|
|
if sandboxDeniedShellResult(result) {
|
|
if reason := strings.TrimSpace(result.Meta[tools.SandboxDenialReasonMeta]); reason != "" {
|
|
return reason
|
|
}
|
|
return "sandbox blocked command execution"
|
|
}
|
|
if sandboxNamespaceLimitedShellResult(result) {
|
|
return sandboxNamespaceLimitedReason
|
|
}
|
|
return "sandbox blocked command execution"
|
|
}
|
|
|
|
func runToolForNetworkRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
|
return registry.RunWithOptions(ctx, name, cloneArgs(args), tools.RunOptions{
|
|
PermissionGranted: true,
|
|
PermissionMode: string(permissionMode),
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
ToolCallID: toolCallID,
|
|
SessionID: options.SessionID,
|
|
Model: options.Model,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
Depth: options.Depth,
|
|
Cwd: options.Cwd,
|
|
FileTracker: options.FileTracker,
|
|
EnabledTools: options.EnabledTools,
|
|
DisabledTools: options.DisabledTools,
|
|
Progress: progressCallback,
|
|
})
|
|
}
|
|
|
|
func unsandboxedRetryArgs(args map[string]any) map[string]any {
|
|
retryArgs := cloneArgs(args)
|
|
if retryArgs == nil {
|
|
retryArgs = map[string]any{}
|
|
}
|
|
retryArgs["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
|
return retryArgs
|
|
}
|
|
|
|
func runToolForUnsandboxedRetry(ctx context.Context, registry *tools.Registry, name string, toolCallID string, args map[string]any, permissionMode PermissionMode, options Options, progressCallback func(streamjson.Event)) tools.Result {
|
|
return registry.RunWithOptions(ctx, name, args, tools.RunOptions{
|
|
PermissionGranted: true,
|
|
PermissionMode: string(permissionMode),
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
ToolCallID: toolCallID,
|
|
SessionID: options.SessionID,
|
|
Model: options.Model,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
Depth: options.Depth,
|
|
Cwd: options.Cwd,
|
|
FileTracker: options.FileTracker,
|
|
EnabledTools: options.EnabledTools,
|
|
DisabledTools: options.DisabledTools,
|
|
Progress: progressCallback,
|
|
})
|
|
}
|
|
|
|
func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolResult {
|
|
output, outputRedacted := scrubInterceptedOutput(result.Output)
|
|
display := result.Display
|
|
summary, summaryRedacted := scrubInterceptedOutput(display.Summary)
|
|
display.Summary = summary
|
|
|
|
meta := result.Meta
|
|
metaRedacted := false
|
|
if len(result.Meta) > 0 {
|
|
meta = make(map[string]string, len(result.Meta))
|
|
for key, value := range result.Meta {
|
|
scrubbed := redaction.RedactString(value, redaction.Options{})
|
|
if scrubbed != value {
|
|
metaRedacted = true
|
|
}
|
|
meta[key] = scrubbed
|
|
}
|
|
}
|
|
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: result.Status,
|
|
Output: output,
|
|
Meta: meta,
|
|
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
|
|
ChangedFiles: result.ChangedFiles,
|
|
Display: display,
|
|
LoadedTools: loadedToolsFromResult(meta),
|
|
RequestedModel: meta["escalate_to_model"],
|
|
}
|
|
}
|
|
|
|
// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
|
|
// that exits non-zero vetoes the call: the returned bool is true and the tool
|
|
// must not run. A nil dispatcher (no hooks wired) is a no-op.
|
|
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
|
|
if options.Hooks == nil {
|
|
return hooks.DispatchOutcome{}, false
|
|
}
|
|
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
|
Event: hooks.EventBeforeTool,
|
|
ToolName: call.Name,
|
|
ToolCallID: call.ID,
|
|
Payload: map[string]any{
|
|
"event": string(hooks.EventBeforeTool),
|
|
"tool": call.Name,
|
|
"toolCallId": call.ID,
|
|
"sessionId": options.SessionID,
|
|
"cwd": options.Cwd,
|
|
"args": args,
|
|
},
|
|
})
|
|
return outcome, outcome.Blocked
|
|
}
|
|
|
|
// dispatchAfterTool runs configured afterTool hooks once a tool has executed and
|
|
// returns any advisory output (e.g. a formatter or vet result) to surface back
|
|
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
|
|
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
|
|
if options.Hooks == nil {
|
|
return ""
|
|
}
|
|
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
|
Event: hooks.EventAfterTool,
|
|
ToolName: call.Name,
|
|
ToolCallID: call.ID,
|
|
Payload: map[string]any{
|
|
"event": string(hooks.EventAfterTool),
|
|
"tool": call.Name,
|
|
"toolCallId": call.ID,
|
|
"sessionId": options.SessionID,
|
|
"cwd": options.Cwd,
|
|
"status": string(result.Status),
|
|
"changedFiles": result.ChangedFiles,
|
|
},
|
|
})
|
|
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
|
|
}
|
|
|
|
// blockedByHookResult is the tool result for a call vetoed by a beforeTool hook.
|
|
func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResult {
|
|
// The hook reason (its stdout/stderr) is model-visible here, an intercepted
|
|
// path that bypasses the registry's output redaction boundary — so scrub it
|
|
// like every other string that crosses into the transcript, and flag Redacted
|
|
// when scrubbing changed it (matching the registry's contract).
|
|
scrubbed := redaction.RedactString(outcome.Reason, redaction.Options{})
|
|
redacted := scrubbed != outcome.Reason
|
|
reason := strings.TrimSpace(scrubbed)
|
|
if reason == "" {
|
|
reason = "blocked by a beforeTool hook"
|
|
}
|
|
message := fmt.Sprintf("Error: %q was blocked by hook %q: %s", call.Name, outcome.BlockedBy, reason)
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: message,
|
|
Redacted: redacted,
|
|
DenialReason: DenialHookBlocked,
|
|
}
|
|
}
|
|
|
|
// appendHookFeedback appends afterTool hook output to a tool result's output,
|
|
// scrubbed for secrets like every other string crossing the tool boundary. The
|
|
// returned bool reports whether scrubbing changed the feedback, so the caller can
|
|
// set ToolResult.Redacted to match the registry's redaction contract.
|
|
func appendHookFeedback(output string, feedback string) (string, bool) {
|
|
scrubbed := redaction.RedactString(feedback, redaction.Options{})
|
|
redacted := scrubbed != feedback
|
|
if strings.TrimSpace(scrubbed) == "" {
|
|
return output, redacted
|
|
}
|
|
if strings.TrimSpace(output) == "" {
|
|
return "Hook output:\n" + scrubbed, redacted
|
|
}
|
|
return output + "\n\nHook output:\n" + scrubbed, redacted
|
|
}
|
|
|
|
// isRetriableToolError reports whether a failed tool result is one the model can
|
|
// plausibly fix by changing its next call (argument or execution failure), as
|
|
// opposed to a policy refusal (disabled tool, permission denial, sandbox
|
|
// block) that no reformatting will satisfy. Only retriable failures should
|
|
// drive the repeated-failure schema hint / stop.
|
|
func isRetriableToolError(result ToolResult) bool {
|
|
if result.Status != tools.StatusError {
|
|
return false
|
|
}
|
|
// A categorized denial (filtered / permission / sandbox) is a policy decision,
|
|
// not a transient failure — never retry it. This is robust to message wording
|
|
// (the text checks below remain as a fallback for results lacking the field).
|
|
if result.DenialReason != DenialNone {
|
|
return false
|
|
}
|
|
if result.Meta["permission_action"] == string(PermissionActionDeny) {
|
|
return false
|
|
}
|
|
switch {
|
|
case strings.Contains(result.Output, "is not enabled for this run"),
|
|
strings.Contains(result.Output, "Permission denied for "),
|
|
strings.Contains(result.Output, "Permission required for "),
|
|
strings.Contains(result.Output, "Sandbox block"),
|
|
strings.Contains(result.Output, "Sandbox approval required for "):
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// scrubInterceptedOutput mirrors the registry's scrubResultSecrets boundary for
|
|
// the loop-intercepted paths (ask_user answers, task child final answers) that
|
|
// build a ToolResult.Output directly instead of going through
|
|
// registry.RunWithOptions. RedactString substitutes "[REDACTED]" inline; the
|
|
// returned bool reports whether anything was scrubbed so the caller can set
|
|
// ToolResult.Redacted, keeping these paths consistent with every other tool
|
|
// result the model and transcript see.
|
|
func scrubInterceptedOutput(output string) (string, bool) {
|
|
scrubbed := redaction.RedactString(output, redaction.Options{})
|
|
return scrubbed, scrubbed != output
|
|
}
|
|
|
|
// executeAskUser routes an ask_user call to the interactive front-end via
|
|
// options.OnAskUser, mirroring the async permission flow. If no handler is set,
|
|
// or the handler errors, it falls back to the tool's own graceful result so the
|
|
// loop never blocks forever waiting on a user who isn't there.
|
|
// executeAskUser returns (result, abortErr). abortErr is non-nil ONLY when the
|
|
// prompt was canceled/timed out and the run must stop with that error; every
|
|
// other path (success, bad args, headless degradation) returns a nil abortErr.
|
|
func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
|
questions, err := tools.ParseAskUserQuestions(args)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Invalid arguments for ask_user: " + err.Error(),
|
|
}, nil
|
|
}
|
|
|
|
if options.OnAskUser == nil {
|
|
return askUserFallbackResult(ctx, registry, call, args, permissionMode, options), nil
|
|
}
|
|
|
|
header, _ := args["header"].(string)
|
|
request := AskUserRequest{
|
|
ToolCallID: call.ID,
|
|
Header: strings.TrimSpace(header),
|
|
Questions: toAgentAskUserQuestions(questions),
|
|
}
|
|
response, err := options.OnAskUser(ctx, request)
|
|
if err != nil {
|
|
// A canceled / timed-out prompt must ABORT the run (return the error), not
|
|
// fabricate a headless answer that keeps mutating the transcript after the UI
|
|
// asked to stop. The error result carries the reason; the abort error stops
|
|
// the loop even when the run context itself was not canceled.
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: ask_user canceled: " + err.Error(),
|
|
}, err
|
|
}
|
|
// Genuine handler unavailability (no interactive surface / non-cancel error):
|
|
// degrade to the same headless path as a missing handler.
|
|
return askUserFallbackResult(ctx, registry, call, args, permissionMode, options), nil
|
|
}
|
|
|
|
// Scrub the formatted answers through the same redaction boundary the
|
|
// registry applies to tool output, so a secret in a user's answer never lands
|
|
// in the transcript unredacted.
|
|
output, redacted := scrubInterceptedOutput(tools.FormatAskUserAnswers(questions, response.Answers))
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusOK,
|
|
Output: output,
|
|
Redacted: redacted,
|
|
}, nil
|
|
}
|
|
|
|
// askUserFallbackResult runs the registered ask_user tool (or returns the shared
|
|
// graceful message) so the no-interactive-user path matches every OTHER tool: it
|
|
// goes through registry.RunWithOptions with the same run context (ToolCallID,
|
|
// session/model/depth/cwd, sandbox/permission) and copies the full result fields
|
|
// (Meta/ChangedFiles/Display), instead of the bare registry.Run that dropped them.
|
|
func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) ToolResult {
|
|
if _, ok := registry.Get(call.Name); ok {
|
|
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
|
|
// ask_user is read-only (PermissionAllow); no prompt/grant is required.
|
|
PermissionGranted: true,
|
|
PermissionMode: string(permissionMode),
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
ToolCallID: call.ID,
|
|
SessionID: options.SessionID,
|
|
Model: options.Model,
|
|
ReasoningEffort: options.ReasoningEffort,
|
|
Depth: options.Depth,
|
|
Cwd: options.Cwd,
|
|
})
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: result.Status,
|
|
Output: result.Output,
|
|
Meta: result.Meta,
|
|
Redacted: result.Redacted,
|
|
ChangedFiles: result.ChangedFiles,
|
|
Display: result.Display,
|
|
}
|
|
}
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusOK,
|
|
Output: tools.AskUserNonInteractiveMessage(),
|
|
}
|
|
}
|
|
|
|
func toAgentAskUserQuestions(questions []tools.AskUserQuestion) []AskUserQuestion {
|
|
converted := make([]AskUserQuestion, len(questions))
|
|
for index, question := range questions {
|
|
converted[index] = AskUserQuestion{
|
|
Question: question.Question,
|
|
Header: question.Header,
|
|
Options: append([]string{}, question.Options...),
|
|
OptionDescriptions: append([]string{}, question.OptionDescriptions...),
|
|
Recommended: question.Recommended,
|
|
MultiSelect: question.MultiSelect,
|
|
}
|
|
}
|
|
return converted
|
|
}
|
|
|
|
func sandboxRequest(toolName string, tool tools.Tool, args map[string]any, permissionGranted bool, permissionMode PermissionMode, options Options) sandbox.Request {
|
|
safety := tool.Safety()
|
|
return sandbox.Request{
|
|
WorkspaceRoot: "",
|
|
ToolName: toolName,
|
|
SideEffect: sandbox.SideEffect(safety.SideEffect),
|
|
Permission: sandbox.Permission(safety.Permission),
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: sandbox.PermissionMode(permissionMode),
|
|
Args: args,
|
|
Reason: safety.Reason,
|
|
}
|
|
}
|
|
|
|
// effectivePermission returns the permission for THIS specific call. A tool that
|
|
// implements tools.ArgsPermissioner may relax its static permission for arguments
|
|
// it can prove are safe (the Task tool auto-allows a read-only sub-agent); every
|
|
// other tool falls back to its static Safety().Permission unchanged.
|
|
func effectivePermission(tool tools.Tool, args map[string]any) tools.Permission {
|
|
if p, ok := tool.(tools.ArgsPermissioner); ok {
|
|
return p.PermissionForArgs(args)
|
|
}
|
|
return tool.Safety().Permission
|
|
}
|
|
|
|
func shouldRequestPermission(tool tools.Tool, args map[string]any, permissionGranted bool, decision *sandbox.Decision) bool {
|
|
if decision != nil && decision.Action == sandbox.ActionPrompt {
|
|
return true
|
|
}
|
|
if tool.Safety().Permission != tools.PermissionPrompt {
|
|
return false
|
|
}
|
|
if !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
|
return true
|
|
}
|
|
if decision != nil {
|
|
if sandboxDecisionRequiresExplicitPermission(decision) {
|
|
return true
|
|
}
|
|
if permissionGranted {
|
|
return false
|
|
}
|
|
return decision.Action == sandbox.ActionPrompt
|
|
}
|
|
if permissionGranted {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sandboxDecisionRequiresExplicitPermission(decision *sandbox.Decision) bool {
|
|
return decision != nil && decision.Action == sandbox.ActionPrompt && decision.Reason == sandbox.ReasonNetworkBlocked
|
|
}
|
|
|
|
func requestPermission(ctx context.Context, request PermissionRequest, options Options) (PermissionDecision, error) {
|
|
if options.OnPermissionRequest == nil {
|
|
return PermissionDecision{Action: PermissionDecisionDeny, Reason: request.Reason}, nil
|
|
}
|
|
return options.OnPermissionRequest(ctx, request)
|
|
}
|
|
|
|
func normalizePermissionDecisionAction(action PermissionDecisionAction) PermissionDecisionAction {
|
|
switch action {
|
|
case PermissionDecisionAllow, PermissionDecisionAllowStrict, PermissionDecisionAllowForSession, PermissionDecisionAllowPrefix, PermissionDecisionAlwaysAllowPrefix, PermissionDecisionAlwaysAllow, PermissionDecisionCancel:
|
|
return action
|
|
default:
|
|
return PermissionDecisionDeny
|
|
}
|
|
}
|
|
|
|
type requestPermissionsArgs struct {
|
|
EnvironmentID string `json:"environment_id"`
|
|
Reason string `json:"reason"`
|
|
Permissions sandbox.RequestPermissionProfile `json:"permissions"`
|
|
}
|
|
|
|
func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
|
|
parsed, err := parseRequestPermissionsArgs(args)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Invalid arguments for request_permissions: " + err.Error(),
|
|
}, nil
|
|
}
|
|
normalized, err := sandbox.NormalizeRequestPermissionProfile(parsed.Permissions, options.Cwd)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
|
}, nil
|
|
}
|
|
if normalized.Empty() {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: request_permissions requires at least one permission.",
|
|
}, nil
|
|
}
|
|
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Invalid permissions for request_permissions: " + err.Error(),
|
|
}, nil
|
|
}
|
|
|
|
request := requestPermissionsPrompt(call, parsed, grantProfile, permissionMode, options)
|
|
if options.OnPermissionRequest == nil {
|
|
response := sandbox.RequestPermissionsResponse{
|
|
Permissions: sandbox.RequestPermissionProfile{},
|
|
Scope: sandbox.PermissionGrantScopeTurn,
|
|
}
|
|
return requestPermissionsResult(call, response, false), nil
|
|
}
|
|
|
|
decision, err := requestPermission(ctx, request, options)
|
|
if err != nil {
|
|
decision = PermissionDecision{Action: PermissionDecisionDeny, Reason: err.Error()}
|
|
}
|
|
decision.Action = normalizePermissionDecisionAction(decision.Action)
|
|
decisionReason := strings.TrimSpace(decision.Reason)
|
|
if decisionReason == "" {
|
|
decisionReason = request.Reason
|
|
}
|
|
|
|
response := sandbox.RequestPermissionsResponse{
|
|
Permissions: sandbox.RequestPermissionProfile{},
|
|
Scope: sandbox.PermissionGrantScopeTurn,
|
|
}
|
|
permissionGranted := false
|
|
switch decision.Action {
|
|
case PermissionDecisionAllow:
|
|
response.Permissions = grantProfile
|
|
response.Scope = sandbox.PermissionGrantScopeTurn
|
|
permissionGranted = true
|
|
case PermissionDecisionAllowStrict:
|
|
response.Permissions = grantProfile
|
|
response.Scope = sandbox.PermissionGrantScopeTurn
|
|
response.StrictAutoReview = true
|
|
permissionGranted = true
|
|
case PermissionDecisionAllowForSession:
|
|
response.Permissions = grantProfile
|
|
response.Scope = sandbox.PermissionGrantScopeSession
|
|
permissionGranted = true
|
|
case PermissionDecisionCancel:
|
|
decision.Action = PermissionDecisionDeny
|
|
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
|
default:
|
|
decision.Action = PermissionDecisionDeny
|
|
decisionReason = firstNonEmptyString(decisionReason, "continued without granting permissions")
|
|
}
|
|
|
|
if permissionGranted {
|
|
if options.Sandbox == nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: request_permissions cannot grant permissions because the sandbox engine is not configured.",
|
|
}, nil
|
|
}
|
|
cleanup, err := options.Sandbox.GrantRequestPermissions(response.Permissions, response.Scope)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Failed to grant requested permissions: " + err.Error(),
|
|
}, nil
|
|
}
|
|
if response.Scope == sandbox.PermissionGrantScopeTurn && options.runPermissions != nil {
|
|
options.runPermissions.add(cleanup)
|
|
}
|
|
}
|
|
|
|
emitRequestPermissionsDecision(options, call, request, decision.Action, decisionReason, permissionGranted)
|
|
return requestPermissionsResult(call, response, false), nil
|
|
}
|
|
|
|
func parseRequestPermissionsArgs(args map[string]any) (requestPermissionsArgs, error) {
|
|
data, err := json.Marshal(args)
|
|
if err != nil {
|
|
return requestPermissionsArgs{}, err
|
|
}
|
|
var parsed requestPermissionsArgs
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
return requestPermissionsArgs{}, err
|
|
}
|
|
if parsed.EnvironmentID == "" {
|
|
if raw, ok := args["environmentId"].(string); ok {
|
|
parsed.EnvironmentID = raw
|
|
}
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func requestPermissionsPrompt(call ToolCall, parsed requestPermissionsArgs, profile sandbox.RequestPermissionProfile, permissionMode PermissionMode, options Options) PermissionRequest {
|
|
autonomy := options.Autonomy
|
|
reason := strings.TrimSpace(parsed.Reason)
|
|
if reason == "" {
|
|
reason = "The agent is requesting additional permissions."
|
|
}
|
|
return PermissionRequest{
|
|
ToolCallID: call.ID,
|
|
ToolName: tools.RequestPermissionsToolName,
|
|
Action: PermissionActionPrompt,
|
|
Permission: string(tools.PermissionPrompt),
|
|
PermissionMode: permissionMode,
|
|
Autonomy: autonomy,
|
|
SideEffect: "permissions",
|
|
Reason: reason,
|
|
Scope: requestPermissionsScope(profile),
|
|
Args: map[string]any{
|
|
"environment_id": parsed.EnvironmentID,
|
|
"reason": parsed.Reason,
|
|
"permissions": profile,
|
|
},
|
|
AvailableDecisions: []PermissionDecisionAction{
|
|
PermissionDecisionAllow,
|
|
PermissionDecisionAllowStrict,
|
|
PermissionDecisionAllowForSession,
|
|
PermissionDecisionDeny,
|
|
},
|
|
}
|
|
}
|
|
|
|
func requestPermissionsScope(profile sandbox.RequestPermissionProfile) string {
|
|
parts := []string{}
|
|
if profile.Network != nil && profile.Network.Enabled != nil && *profile.Network.Enabled {
|
|
parts = append(parts, "network all outbound access")
|
|
}
|
|
if profile.FileSystem != nil {
|
|
for _, path := range profile.FileSystem.Read {
|
|
parts = append(parts, "read "+path)
|
|
}
|
|
for _, path := range profile.FileSystem.Write {
|
|
parts = append(parts, "write "+path)
|
|
}
|
|
for _, path := range profile.FileSystem.DenyRead {
|
|
parts = append(parts, "deny read "+path)
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func emitRequestPermissionsDecision(options Options, call ToolCall, request PermissionRequest, action PermissionDecisionAction, reason string, granted bool) {
|
|
if options.OnPermission == nil {
|
|
return
|
|
}
|
|
eventAction := PermissionActionDeny
|
|
if granted {
|
|
eventAction = PermissionActionAllow
|
|
}
|
|
options.OnPermission(PermissionEvent{
|
|
ToolCallID: call.ID,
|
|
ToolName: call.Name,
|
|
Action: eventAction,
|
|
DecisionAction: action,
|
|
Permission: request.Permission,
|
|
PermissionGranted: granted,
|
|
PermissionMode: request.PermissionMode,
|
|
Autonomy: request.Autonomy,
|
|
SideEffect: request.SideEffect,
|
|
Reason: reason,
|
|
Scope: request.Scope,
|
|
DecisionReason: reason,
|
|
})
|
|
}
|
|
|
|
func requestPermissionsResult(call ToolCall, response sandbox.RequestPermissionsResponse, redacted bool) ToolResult {
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Failed to serialize request_permissions response: " + err.Error(),
|
|
}
|
|
}
|
|
output, didRedact := scrubInterceptedOutput(string(data))
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusOK,
|
|
Output: output,
|
|
Redacted: redacted || didRedact,
|
|
}
|
|
}
|
|
|
|
func firstNonEmptyString(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func persistPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
|
if options.Sandbox == nil {
|
|
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
|
}
|
|
// Scope the grant to exactly what the permission card showed (the file or
|
|
// directory the call touches); engine.Grant anchors it to the workspace. A
|
|
// call with no path-like argument yields an empty scope — a tool-wide grant.
|
|
scope, kind := sandbox.DeriveScope(toolName, args)
|
|
return options.Sandbox.Grant(sandbox.GrantInput{
|
|
ToolName: toolName,
|
|
Decision: sandbox.GrantAllow,
|
|
Reason: reason,
|
|
Scope: scope,
|
|
ScopeKind: kind,
|
|
})
|
|
}
|
|
|
|
func persistSessionPermissionGrant(toolName string, args map[string]any, reason string, options Options) (sandbox.Grant, error) {
|
|
if options.Sandbox == nil {
|
|
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
|
}
|
|
scope, kind := sandbox.DeriveScope(toolName, args)
|
|
return options.Sandbox.GrantForSession(sandbox.GrantInput{
|
|
ToolName: toolName,
|
|
Decision: sandbox.GrantAllow,
|
|
Reason: reason,
|
|
Scope: scope,
|
|
ScopeKind: kind,
|
|
})
|
|
}
|
|
|
|
func persistCommandPrefixGrant(toolName string, prefix []string, reason string, options Options) (sandbox.CommandPrefixGrant, error) {
|
|
if options.Sandbox == nil {
|
|
return sandbox.CommandPrefixGrant{}, errors.New("sandbox engine is not configured")
|
|
}
|
|
return options.Sandbox.GrantCommandPrefix(sandbox.CommandPrefixInput{
|
|
ToolName: toolName,
|
|
Prefix: prefix,
|
|
Reason: reason,
|
|
})
|
|
}
|
|
|
|
func emitDeniedPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
|
if options.OnPermission == nil {
|
|
return
|
|
}
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = requestEvent.Reason
|
|
}
|
|
event := requestEvent
|
|
event.ToolCallID = call.ID
|
|
event.ToolName = call.Name
|
|
event.Action = PermissionActionDeny
|
|
event.DecisionAction = PermissionDecisionDeny
|
|
event.PermissionGranted = false
|
|
event.DecisionReason = reason
|
|
options.OnPermission(event)
|
|
}
|
|
|
|
func emitCanceledPermission(options Options, call ToolCall, requestEvent PermissionEvent, reason string) {
|
|
if options.OnPermission == nil {
|
|
return
|
|
}
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = "cancelled in TUI"
|
|
}
|
|
event := requestEvent
|
|
event.ToolCallID = call.ID
|
|
event.ToolName = call.Name
|
|
event.Action = PermissionActionCancel
|
|
event.DecisionAction = PermissionDecisionCancel
|
|
event.PermissionGranted = false
|
|
event.DecisionReason = reason
|
|
event.Reason = reason
|
|
options.OnPermission(event)
|
|
}
|
|
|
|
func deniedPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = requestEvent.Reason
|
|
}
|
|
if reason == "" {
|
|
reason = "tool requires approval before execution"
|
|
}
|
|
event := requestEvent
|
|
event.Action = PermissionActionDeny
|
|
event.DecisionAction = PermissionDecisionDeny
|
|
event.PermissionGranted = false
|
|
event.DecisionReason = reason
|
|
if event.Risk.Level == "" {
|
|
event.Risk = sandbox.Risk{Level: sandbox.RiskMedium, Reason: reason}
|
|
}
|
|
if requestEvent.ToolName == "" {
|
|
event.ToolName = call.Name
|
|
}
|
|
// A denial driven by a sandbox block is categorized distinctly from a
|
|
// plain approval-declined so a surface can tell policy from user choice.
|
|
denial := DenialPermissionDenied
|
|
if requestEvent.Block != nil {
|
|
denial = DenialSandboxBlock
|
|
}
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Permission denied for " + call.Name + ": " + reason,
|
|
DenialReason: denial,
|
|
Meta: map[string]string{
|
|
"permission_action": string(event.Action),
|
|
},
|
|
}
|
|
}
|
|
|
|
func canceledPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = "cancelled in TUI"
|
|
}
|
|
event := requestEvent
|
|
event.Action = PermissionActionCancel
|
|
event.DecisionAction = PermissionDecisionCancel
|
|
event.PermissionGranted = false
|
|
event.DecisionReason = reason
|
|
event.Reason = reason
|
|
if requestEvent.ToolName == "" {
|
|
event.ToolName = call.Name
|
|
}
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Permission approval cancelled for " + call.Name + ": " + reason,
|
|
DenialReason: DenialApprovalCanceled,
|
|
Meta: map[string]string{
|
|
"permission_action": string(event.Action),
|
|
},
|
|
}
|
|
}
|
|
|
|
// permissionScope returns a concise, human-readable description of what a tool
|
|
// call will actually touch -- a file path, directory, working dir, or network
|
|
// host lifted from its arguments -- so the permission card and persisted
|
|
// decision can show the user exactly what "allow" covers. It is empty when the
|
|
// tool exposes no scoped argument (the grant is then plainly tool-wide). The
|
|
// first matching key wins, ordered most-specific (a concrete file) first.
|
|
func permissionScope(toolName string, args map[string]any) string {
|
|
// sandbox.DeriveScope is the single source of truth for which arguments carry
|
|
// a scope, shared with grant persistence and matching so the card display can
|
|
// never diverge from what an "always allow" actually covers.
|
|
raw, _ := sandbox.DeriveScope(toolName, args)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
return truncateScope(raw)
|
|
}
|
|
|
|
func truncateScope(value string) string {
|
|
const maxScopeRunes = 80
|
|
runes := []rune(value)
|
|
if len(runes) <= maxScopeRunes {
|
|
return value
|
|
}
|
|
return string(runes[:maxScopeRunes-1]) + "…"
|
|
}
|
|
|
|
func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionGranted bool, permissionMode PermissionMode, options Options, decision *sandbox.Decision) (PermissionEvent, bool) {
|
|
safety := tool.Safety()
|
|
var action PermissionAction
|
|
reason := safety.Reason
|
|
risk := sandbox.Classify(sandbox.Request{
|
|
WorkspaceRoot: "",
|
|
ToolName: call.Name,
|
|
SideEffect: sandbox.SideEffect(safety.SideEffect),
|
|
Permission: sandbox.Permission(safety.Permission),
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: sandbox.PermissionMode(permissionMode),
|
|
Args: args,
|
|
Reason: safety.Reason,
|
|
})
|
|
var block *sandbox.Block
|
|
grantMatched := false
|
|
var grant *sandbox.Grant
|
|
|
|
if decision != nil {
|
|
action = permissionActionFromSandbox(decision.Action)
|
|
if decision.Reason != "" {
|
|
reason = decision.Reason
|
|
}
|
|
risk = decision.Risk
|
|
block = decision.Block
|
|
grantMatched = decision.GrantMatched
|
|
grant = decision.Grant
|
|
} else {
|
|
switch safety.Permission {
|
|
case tools.PermissionDeny:
|
|
action = PermissionActionDeny
|
|
case tools.PermissionPrompt:
|
|
if permissionGranted {
|
|
action = PermissionActionAllow
|
|
} else {
|
|
action = PermissionActionPrompt
|
|
}
|
|
default:
|
|
return PermissionEvent{}, false
|
|
}
|
|
}
|
|
|
|
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
|
return PermissionEvent{}, false
|
|
}
|
|
|
|
scopeText := permissionScope(call.Name, args)
|
|
if profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd); ok && err == nil {
|
|
scopeText = requestPermissionsScope(profile)
|
|
}
|
|
reason = userFacingPermissionReason(call.Name, args, reason)
|
|
|
|
return PermissionEvent{
|
|
ToolCallID: call.ID,
|
|
ToolName: call.Name,
|
|
Action: action,
|
|
DecisionAction: grantDecisionAction(grant),
|
|
Permission: string(safety.Permission),
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: permissionMode,
|
|
Autonomy: options.Autonomy,
|
|
SideEffect: string(safety.SideEffect),
|
|
Reason: reason,
|
|
Scope: scopeText,
|
|
Risk: risk,
|
|
Block: block,
|
|
GrantMatched: grantMatched,
|
|
Grant: grant,
|
|
CommandPrefix: proposedCommandPrefix(call.Name, args),
|
|
}, true
|
|
}
|
|
|
|
func userFacingPermissionReason(toolName string, args map[string]any, reason string) string {
|
|
reason = strings.TrimSpace(reason)
|
|
if !isShellCommandTool(toolName) || !shellCommandRequiresEscalated(args) {
|
|
return reason
|
|
}
|
|
if justification, ok := firstStringArg(args, "justification"); ok {
|
|
return justification
|
|
}
|
|
if reason == "" || reason == sandbox.ReasonEscalatedSandboxRequired {
|
|
return "This command needs to run outside the sandbox."
|
|
}
|
|
return reason
|
|
}
|
|
|
|
func grantDecisionAction(grant *sandbox.Grant) PermissionDecisionAction {
|
|
if grant == nil {
|
|
return ""
|
|
}
|
|
if grant.Session {
|
|
return PermissionDecisionAllowForSession
|
|
}
|
|
if grant.Decision == sandbox.GrantAllow {
|
|
return PermissionDecisionAlwaysAllow
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fallbackPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, permissionMode PermissionMode, options Options) PermissionEvent {
|
|
event, _ := buildPermissionEvent(call, tool, args, false, permissionMode, options, nil)
|
|
return event
|
|
}
|
|
|
|
func permissionRequestFromEvent(event PermissionEvent, args map[string]any, options Options) PermissionRequest {
|
|
return PermissionRequest{
|
|
ToolCallID: event.ToolCallID,
|
|
ToolName: event.ToolName,
|
|
Action: event.Action,
|
|
Permission: event.Permission,
|
|
PermissionMode: event.PermissionMode,
|
|
Autonomy: event.Autonomy,
|
|
SideEffect: event.SideEffect,
|
|
Reason: event.Reason,
|
|
Scope: event.Scope,
|
|
Risk: event.Risk,
|
|
Args: cloneArgs(args),
|
|
Block: event.Block,
|
|
GrantMatched: event.GrantMatched,
|
|
Grant: event.Grant,
|
|
CommandPrefix: append([]string(nil), event.CommandPrefix...),
|
|
AvailableDecisions: availablePermissionDecisions(event, args, options),
|
|
}
|
|
}
|
|
|
|
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
|
|
if event.Action != PermissionActionPrompt {
|
|
return nil
|
|
}
|
|
decisions := []PermissionDecisionAction{PermissionDecisionAllow}
|
|
inlineAdditionalPermissions := shellCommandAdditionalPermissionsRequested(args)
|
|
if options.Sandbox != nil {
|
|
decisions = append(decisions, PermissionDecisionAllowForSession)
|
|
if isShellCommandTool(event.ToolName) && len(event.CommandPrefix) > 0 && !networkSandboxPrompt(event) && !inlineAdditionalPermissions {
|
|
decisions = append(decisions, PermissionDecisionAllowPrefix)
|
|
if options.Sandbox.CanPersistGrants() {
|
|
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
|
|
}
|
|
}
|
|
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
|
|
decisions = append(decisions, PermissionDecisionAlwaysAllow)
|
|
}
|
|
}
|
|
switch {
|
|
case isShellCommandTool(event.ToolName):
|
|
decisions = append(decisions, PermissionDecisionDeny, PermissionDecisionCancel)
|
|
case event.ToolName == "apply_patch":
|
|
decisions = append(decisions, PermissionDecisionCancel)
|
|
default:
|
|
decisions = append(decisions, PermissionDecisionDeny)
|
|
}
|
|
return decisions
|
|
}
|
|
|
|
func networkSandboxPrompt(event PermissionEvent) bool {
|
|
return isShellCommandTool(event.ToolName) &&
|
|
event.Reason == sandbox.ReasonNetworkBlocked &&
|
|
sandbox.HasRiskCategory(event.Risk, "network")
|
|
}
|
|
|
|
func ensureRiskCategory(risk sandbox.Risk, category string, level sandbox.RiskLevel) sandbox.Risk {
|
|
if !sandbox.HasRiskCategory(risk, category) {
|
|
risk.Categories = append(risk.Categories, category)
|
|
sort.Strings(risk.Categories)
|
|
}
|
|
risk.Level = level
|
|
if len(risk.Categories) > 0 {
|
|
risk.Reason = fmt.Sprintf("%s risk: %s", risk.Level, strings.Join(risk.Categories, ", "))
|
|
}
|
|
return risk
|
|
}
|
|
|
|
func shellCommandAdditionalPermissionsRequested(args map[string]any) bool {
|
|
raw, ok := args["sandbox_permissions"]
|
|
if !ok || raw == nil {
|
|
return false
|
|
}
|
|
value, ok := raw.(string)
|
|
if !ok {
|
|
value = fmt.Sprint(raw)
|
|
}
|
|
return strings.TrimSpace(value) == string(tools.SandboxPermissionsWithAdditionalPermissions)
|
|
}
|
|
|
|
func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (sandbox.RequestPermissionProfile, bool, error) {
|
|
if !shellCommandAdditionalPermissionsRequested(args) {
|
|
if _, exists := args["additional_permissions"]; exists {
|
|
return sandbox.RequestPermissionProfile{}, false, fmt.Errorf("additional_permissions requires sandbox_permissions set to %q", tools.SandboxPermissionsWithAdditionalPermissions)
|
|
}
|
|
return sandbox.RequestPermissionProfile{}, false, nil
|
|
}
|
|
raw, ok := args["additional_permissions"]
|
|
if !ok || raw == nil {
|
|
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
|
}
|
|
data, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return sandbox.RequestPermissionProfile{}, true, err
|
|
}
|
|
var profile sandbox.RequestPermissionProfile
|
|
if err := json.Unmarshal(data, &profile); err != nil {
|
|
return sandbox.RequestPermissionProfile{}, true, err
|
|
}
|
|
normalized, err := sandbox.NormalizeRequestPermissionProfile(profile, basePath)
|
|
if err != nil {
|
|
return sandbox.RequestPermissionProfile{}, true, err
|
|
}
|
|
if normalized.Empty() {
|
|
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
|
}
|
|
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
|
if err != nil {
|
|
return sandbox.RequestPermissionProfile{}, true, err
|
|
}
|
|
return grantProfile, true, nil
|
|
}
|
|
|
|
func grantInlineAdditionalPermissions(args map[string]any, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
|
|
profile, ok, err := inlineAdditionalPermissionsProfile(args, options.Cwd)
|
|
if err != nil || !ok {
|
|
return nil, err
|
|
}
|
|
if options.Sandbox == nil {
|
|
return nil, errors.New("sandbox engine is not configured")
|
|
}
|
|
return options.Sandbox.GrantRequestPermissions(profile, scope)
|
|
}
|
|
|
|
func grantNetworkForSandboxPrompt(event PermissionEvent, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
|
|
if !networkSandboxPrompt(event) || options.Sandbox == nil {
|
|
return nil, nil
|
|
}
|
|
enabled := true
|
|
return options.Sandbox.GrantRequestPermissions(sandbox.RequestPermissionProfile{
|
|
Network: &sandbox.NetworkPermissions{Enabled: &enabled},
|
|
}, scope)
|
|
}
|
|
|
|
func filesystemSandboxPrompt(event PermissionEvent) bool {
|
|
return event.Block != nil &&
|
|
event.Block.Code == sandbox.BlockOutsideWorkspace &&
|
|
event.Block.Recoverable &&
|
|
strings.TrimSpace(event.Block.Path) != ""
|
|
}
|
|
|
|
func grantFilesystemForSandboxPrompt(event PermissionEvent, scope sandbox.PermissionGrantScope, options Options) (func(), error) {
|
|
if !filesystemSandboxPrompt(event) || options.Sandbox == nil {
|
|
return nil, nil
|
|
}
|
|
path := event.Block.Path
|
|
fs := sandbox.FileSystemPermissions{}
|
|
switch sandbox.SideEffect(event.SideEffect) {
|
|
case sandbox.SideEffectRead:
|
|
fs.Read = []string{path}
|
|
case sandbox.SideEffectWrite, sandbox.SideEffectOutOfWorkspace:
|
|
fs.Write = []string{path}
|
|
default:
|
|
return nil, nil
|
|
}
|
|
return options.Sandbox.GrantRequestPermissions(sandbox.RequestPermissionProfile{
|
|
FileSystem: &fs,
|
|
}, scope)
|
|
}
|
|
|
|
func permissionSupportsPersistentDecision(toolName string) bool {
|
|
switch toolName {
|
|
case "bash", "exec_command", "write_stdin", "apply_patch":
|
|
return false
|
|
case "browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "browser_action", "desktop_windows", "desktop_snapshot", "desktop_action", "terminal_session", "capture_artifact":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func cloneArgs(args map[string]any) map[string]any {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
copied := make(map[string]any, len(args))
|
|
for key, value := range args {
|
|
copied[key] = value
|
|
}
|
|
return copied
|
|
}
|
|
|
|
func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
|
switch action {
|
|
case sandbox.ActionAllow:
|
|
return PermissionActionAllow
|
|
case sandbox.ActionDeny:
|
|
return PermissionActionDeny
|
|
default:
|
|
return PermissionActionPrompt
|
|
}
|
|
}
|
|
|
|
// partitionTools builds the per-turn advertised tool list and optional
|
|
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
|
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
|
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
|
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
|
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
|
// through tool_search. Non-deferred tools (including tool_search) are always
|
|
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
|
// so the inactive path is stable.
|
|
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
|
registeredTools := registry.All()
|
|
|
|
visible := make([]tools.Tool, 0, len(registeredTools))
|
|
eligible := 0
|
|
for _, tool := range registeredTools {
|
|
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
|
|
continue
|
|
}
|
|
visible = append(visible, tool)
|
|
// Count by deferral-ELIGIBILITY, not current deferred state: a tool that
|
|
// un-defers at runtime (e.g. swarm coordination tools once a swarm is
|
|
// active) still counts, so it can't drop `eligible` below the threshold
|
|
// and force-expose every other deferred tool. Whether a tool is actually
|
|
// hidden is decided by IsDeferred in the exposure loop below.
|
|
if tools.IsDeferralEligible(tool) {
|
|
eligible++
|
|
}
|
|
}
|
|
|
|
// Deferral may activate only when tool_search is actually runnable; otherwise
|
|
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
|
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
|
// registered, not in DisabledTools, and advertised in the current permission
|
|
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
|
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
|
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
|
// not disable deferral.
|
|
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
|
loaderUsable := loaderFound &&
|
|
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
|
ToolAdvertised(loader, permissionMode)
|
|
|
|
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
|
|
|
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
|
exposedNames := make(map[string]bool, len(visible))
|
|
var hiddenTools []tools.Tool
|
|
for _, tool := range visible {
|
|
name := tool.Name()
|
|
deferred := tools.IsDeferred(tool)
|
|
|
|
if !active {
|
|
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
|
if name == tools.ToolSearchToolName {
|
|
continue
|
|
}
|
|
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
Name: name,
|
|
Description: tool.Description(),
|
|
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
|
})
|
|
exposedNames[name] = true
|
|
continue
|
|
}
|
|
|
|
// Active path.
|
|
if deferred && !loaded[name] {
|
|
hiddenTools = append(hiddenTools, tool)
|
|
continue
|
|
}
|
|
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
Name: name,
|
|
Description: tool.Description(),
|
|
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
|
})
|
|
exposedNames[name] = true
|
|
}
|
|
|
|
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
|
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
|
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
|
// tools, not the loader). Expose its full definition whenever it is not
|
|
// already in the exposed set. This never runs on the inactive path, so the
|
|
// byte-identical below-threshold output is preserved.
|
|
discovery := ""
|
|
if active && len(hiddenTools) > 0 {
|
|
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
|
}
|
|
if active && !exposedNames[tools.ToolSearchToolName] {
|
|
description := loader.Description()
|
|
if discovery != "" {
|
|
description = discovery
|
|
}
|
|
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
Name: loader.Name(),
|
|
Description: description,
|
|
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
|
})
|
|
} else if active && discovery != "" {
|
|
for i := range definitions {
|
|
if definitions[i].Name == tools.ToolSearchToolName {
|
|
definitions[i].Description = discovery
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(definitions, func(left int, right int) bool {
|
|
return definitions[left].Name < definitions[right].Name
|
|
})
|
|
|
|
return definitions, discovery
|
|
}
|
|
|
|
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
|
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
|
}
|
|
|
|
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
|
if len(enabledTools) > 0 {
|
|
if !containsToolName(enabledTools, name) {
|
|
return false
|
|
}
|
|
}
|
|
if containsToolName(disabledTools, name) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsToolName(names []string, name string) bool {
|
|
for _, candidate := range names {
|
|
if candidate == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func schemaToRuntimeMap(schema tools.Schema) map[string]any {
|
|
parameters := map[string]any{
|
|
"type": schema.Type,
|
|
"additionalProperties": schema.AdditionalProperties,
|
|
}
|
|
|
|
if len(schema.Required) > 0 {
|
|
parameters["required"] = append([]string{}, schema.Required...)
|
|
}
|
|
|
|
if len(schema.Properties) > 0 {
|
|
properties := make(map[string]any, len(schema.Properties))
|
|
for name, property := range schema.Properties {
|
|
properties[name] = propertyToRuntimeMap(property)
|
|
}
|
|
parameters["properties"] = properties
|
|
}
|
|
|
|
return parameters
|
|
}
|
|
|
|
func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
|
|
schema := map[string]any{
|
|
"type": property.Type,
|
|
}
|
|
if property.Description != "" {
|
|
schema["description"] = property.Description
|
|
}
|
|
if len(property.Enum) > 0 {
|
|
schema["enum"] = append([]string{}, property.Enum...)
|
|
}
|
|
if property.Default != nil {
|
|
schema["default"] = property.Default
|
|
}
|
|
if property.Items != nil {
|
|
schema["items"] = propertyToRuntimeMap(*property.Items)
|
|
}
|
|
if property.Minimum != nil {
|
|
schema["minimum"] = *property.Minimum
|
|
}
|
|
if property.Maximum != nil {
|
|
schema["maximum"] = *property.Maximum
|
|
}
|
|
if len(property.Properties) > 0 {
|
|
properties := make(map[string]any, len(property.Properties))
|
|
for name, nested := range property.Properties {
|
|
properties[name] = propertyToRuntimeMap(nested)
|
|
}
|
|
schema["properties"] = properties
|
|
}
|
|
if len(property.Required) > 0 {
|
|
schema["required"] = append([]string{}, property.Required...)
|
|
}
|
|
return schema
|
|
}
|
|
|
|
func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
|
|
if tool.Safety().Permission == tools.PermissionDeny {
|
|
return false
|
|
}
|
|
if permissionMode == PermissionModeSpecDraft {
|
|
return toolAdvertisedInSpecDraft(tool)
|
|
}
|
|
if permissionMode == PermissionModeAuto {
|
|
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
|
|
}
|
|
if permissionMode == PermissionModeMemberAuto {
|
|
// Like Auto, plus the in-workspace mutators a headless member needs to
|
|
// build. The sandbox engine still decides at call time: in-workspace writes
|
|
// and sandbox-backed shell auto-allow, while out-of-workspace writes,
|
|
// network, and destructive commands prompt → denied headless. So this
|
|
// advertises capability without widening sandbox authority.
|
|
if tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto {
|
|
return true
|
|
}
|
|
switch tool.Safety().SideEffect {
|
|
case tools.SideEffectWrite, tools.SideEffectShell:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
|
|
switch tool.Name() {
|
|
case "ask_user", "submit_spec":
|
|
return true
|
|
case "update_plan":
|
|
return false
|
|
}
|
|
safety := tool.Safety()
|
|
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
|
|
}
|
|
|
|
func stopReasonFromToolResult(result ToolResult) StopReason {
|
|
if result.Meta == nil {
|
|
return ""
|
|
}
|
|
if result.Meta[toolResultMetaControl] == toolResultControlSpecReview {
|
|
return StopReasonSpecReviewRequired
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// loadedToolsFromResult extracts the deferred-tool names a tool (tool_search)
|
|
// asked the loop to expose next turn from Meta["load_tools"] (comma-separated).
|
|
// It trims each name and drops empties; returns nil when the key is absent or
|
|
// yields no names, so an ordinary result keeps a nil LoadedTools. Mirrors the
|
|
// Meta-driven control signal read by stopReasonFromToolResult.
|
|
func loadedToolsFromResult(meta map[string]string) []string {
|
|
raw := meta["load_tools"]
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
var names []string
|
|
for _, part := range strings.Split(raw, ",") {
|
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
|
names = append(names, trimmed)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
// appendAbortedToolResults adds a placeholder tool-result message for each of
|
|
// the given (unexecuted) tool calls, so every advertised tool_use keeps a
|
|
// matching tool_result when the loop halts a turn before all calls have run.
|
|
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
|
|
for _, call := range remaining {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleTool,
|
|
Content: abortedToolResultNotice,
|
|
ToolCallID: call.ID,
|
|
})
|
|
}
|
|
return messages
|
|
}
|
|
|
|
// dedupeStrings returns values with duplicates removed, preserving first-seen
|
|
// order so the deduped list (e.g. a turn's changed files) stays deterministic.
|
|
func dedupeStrings(values []string) []string {
|
|
seen := make(map[string]bool, len(values))
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func copyMessages(messages []Message) []Message {
|
|
copied := make([]Message, len(messages))
|
|
for index, message := range messages {
|
|
copied[index] = message
|
|
if message.ToolCalls != nil {
|
|
copied[index].ToolCalls = append([]ToolCall{}, message.ToolCalls...)
|
|
}
|
|
if message.Reasoning != nil {
|
|
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
|
|
}
|
|
// Deep-copy image attachments (slice AND each Data byte slice) so the
|
|
// raw image bytes are never aliased across history/request/result copies.
|
|
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
|
|
}
|
|
return copied
|
|
}
|