mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(tools): universal token-based output ceiling with spill-to-disk
Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.
This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all
Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.
bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.
* perf(agent): collect post-edit LSP diagnostics off the tool-call path
Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.
Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.
A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.
The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.
* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening
- Drain background diagnostics before the max-turns final-answer request
so an error introduced by the LAST turn's edit is reported in the
summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
marker when boundedBuffer dropped the middle of a stream, so a spilled
log never reads as contiguous when it is not; junction position is
asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
spill file content; exemption-list test gains read_minified_file.
* fix(test): use a real workspace root in async diagnostics tests
filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.
* fix: pattern-scrub spill files and gate finalization on pending diagnostics
Review follow-ups (Vasanthdev2004):
- Spill files now pass the pattern-based secret scanner (AWS keys,
GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
configured-key scrub. A bash spill runs before formatBashOutput's
scan, so without this a spilled file held in cleartext exactly the
credentials the transcript redacts. Applied centrally in
spillTruncatedOutput, covering bash, exec_command, and ceiling spills.
- Finalization diagnostics gate: a no-tool-call final answer now drains
pending post-edit diagnostics with the full inline-era budget (10s,
vs the 3s per-turn wait) before the run is accepted; introduced
errors append the nudge and give the model one more turn instead of
being silently dropped when no later turn exists. The max-turns
summary path uses the same finalization budget. Free for runs that
never edited — an idle collector returns immediately.
2912 lines
118 KiB
Go
2912 lines
118 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.
|
||
//
|
||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||
// while bounding the worst case to ~2× the idle timeout.
|
||
const maxStreamStallRetries = 1
|
||
|
||
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)
|
||
|
||
// Background post-edit diagnostics: files changed by mutating tools are
|
||
// checked off the tool-call critical path and any errors are appended as a
|
||
// nudge before the NEXT request (see async_diagnostics.go). nil when no
|
||
// FileDiagnostics callback is wired; every method no-ops on nil.
|
||
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
|
||
|
||
// 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
|
||
|
||
// toolDefCache memoizes each tool's rendered JSON-schema definition across
|
||
// turns (a tool's advertised schema is stable for the run), so partitionTools
|
||
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||
|
||
result := Result{Messages: copyMessages(messages)}
|
||
for turn := 0; turn < maxTurns; turn++ {
|
||
result.Turns = turn + 1
|
||
|
||
// Deliver background post-edit diagnostics from the previous turn's edits
|
||
// BEFORE compaction so the nudge is part of the request being budgeted.
|
||
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
|
||
// simply delivers on a later turn.
|
||
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
|
||
messages = append(messages, zeroruntime.Message{
|
||
Role: zeroruntime.MessageRoleUser,
|
||
Content: nudge,
|
||
})
|
||
}
|
||
|
||
// 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, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||
|
||
// 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,
|
||
PromptCacheKey: options.SessionID,
|
||
}
|
||
|
||
// 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,
|
||
PromptCacheKey: options.SessionID,
|
||
}
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
|
||
// the only output whose re-stream on a stall retry would visibly DUPLICATE
|
||
// answer text the user already read. Transient working indicators
|
||
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
|
||
// counted: a turn that streamed only those then stalled — the common
|
||
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
|
||
// the TUI resets the tool-call preview on the next tool-call-start and a
|
||
// stalled turn is never appended to `messages` (it returns on the error
|
||
// before the append), so the retry re-sends clean context with no
|
||
// conversation-state duplication.
|
||
forwardedVisibleText := false
|
||
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
|
||
if options.OnText != nil {
|
||
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
|
||
}
|
||
if options.OnReasoning != nil {
|
||
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
|
||
}
|
||
if options.OnToolCallStart != nil {
|
||
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
|
||
}
|
||
if options.OnToolCallDelta != nil {
|
||
forwardingOpts.OnToolCallDelta = func(id, fragment string) { 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,
|
||
PromptCacheKey: options.SessionID,
|
||
}
|
||
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 is safely re-issued when the turn committed NO
|
||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||
// collected final text (collected.Text). This covers two cases:
|
||
// 1. Nothing streamed at all before the connection died (the original
|
||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||
// large write_file) then froze mid-arguments — the common gpt-5.x /
|
||
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
|
||
// (a turn with collected.Error returns before dispatch below) and NEVER
|
||
// appended to `messages`, so a retry re-issues clean context; the only
|
||
// re-render is transient previews, not duplicated answer text. This is
|
||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||
// answer text) and falls through to the error return below. Capped +
|
||
// exponential backoff, with a user-visible notice per attempt.
|
||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||
collected.Text == ""; attempt++ {
|
||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||
notify(attempt, maxStreamStallRetries)
|
||
}
|
||
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,
|
||
PromptCacheKey: options.SessionID,
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Calibrate the compaction token estimator against the provider's real
|
||
// prompt-token count for the request we just sent, so later turns trigger
|
||
// compaction near true capacity instead of ~15% early on code-heavy history.
|
||
// Recompute the estimate HERE (not at request-build time): a reactive
|
||
// compaction may have replaced `messages` with a smaller set and re-sent, so
|
||
// this reflects the request that actually produced collected.Usage. The
|
||
// assistant reply is appended below, after this, so `messages` is still the
|
||
// sent request.
|
||
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
|
||
|
||
// 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
|
||
}
|
||
}
|
||
// Finalization diagnostics gate: edits from this run may still have
|
||
// checks in flight — the per-turn drain waits only briefly and defers
|
||
// to "a later turn", but a final answer means there is no later turn.
|
||
// Wait out the full inline-era budget once; an introduced error gives
|
||
// the model one more turn to see (and fix) it instead of being lost.
|
||
// Free for runs that never edited: an idle collector returns "".
|
||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||
messages = append(messages, zeroruntime.Message{
|
||
Role: zeroruntime.MessageRoleUser,
|
||
Content: nudge,
|
||
})
|
||
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
|
||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||
// executed concurrently, consumed strictly in order below.
|
||
var precomputed []precomputedToolResult
|
||
precomputedStart, precomputedEnd := 0, 0
|
||
for index, call := range collected.ToolCalls {
|
||
// When this call starts a consecutive run of >= 2 auto-allowed read-only
|
||
// calls, execute the whole run concurrently now (see parallel_tools.go).
|
||
// The scan happens lazily at the run's first index — never ahead of a
|
||
// pending mutating call — so read-after-write ordering is preserved.
|
||
if index >= precomputedEnd {
|
||
runEnd := index
|
||
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||
runEnd++
|
||
}
|
||
if runEnd-index >= 2 {
|
||
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||
precomputedStart, precomputedEnd = index, runEnd
|
||
}
|
||
}
|
||
if options.OnToolCall != nil {
|
||
options.OnToolCall(call)
|
||
}
|
||
var toolResult ToolResult
|
||
var abortErr error
|
||
if index >= precomputedStart && index < precomputedEnd {
|
||
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
|
||
} else {
|
||
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)
|
||
}
|
||
|
||
// Collect the files this successful mutating tool changed. Self-correct
|
||
// verification runs once over the union after the batch; background
|
||
// diagnostics start NOW so the language server analyzes while the rest
|
||
// of the batch executes. A read-only tool (no ChangedFiles) never
|
||
// contributes.
|
||
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||
postEditDiagnostics.enqueue(ctx, 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.
|
||
//
|
||
// The final-answer call is one more model request, so diagnostics from the
|
||
// LAST turn's edits get a drain too — with the finalization budget, since
|
||
// there is no later turn to defer to — otherwise an error introduced by
|
||
// the final edit would go unreported in the summary.
|
||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||
messages = append(messages, zeroruntime.Message{
|
||
Role: zeroruntime.MessageRoleUser,
|
||
Content: nudge,
|
||
})
|
||
}
|
||
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,
|
||
PromptCacheKey: options.SessionID,
|
||
}, 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
|
||
}
|
||
}
|
||
}
|
||
|
||
// A shell command that sets sandbox_permissions: with_additional_permissions
|
||
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
|
||
// is the single source of truth for that shape, and buildPermissionEvent (below)
|
||
// calls it again to render the prompt's scope text. Check it here, before any
|
||
// permission prompt is shown: a malformed payload can never be satisfied no
|
||
// matter what the user decides, so surfacing it as a plain tool error (which the
|
||
// model can see and retry) is both clearer and avoids presenting a normal-looking
|
||
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
|
||
if toolFound && isShellCommandTool(call.Name) {
|
||
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
|
||
return ToolResult{
|
||
ToolCallID: call.ID,
|
||
Name: call.Name,
|
||
Status: tools.StatusError,
|
||
Output: "Error: " + err.Error(),
|
||
}, 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,
|
||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||
// synchronously inside edit_file/write_file and block every mutating tool
|
||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||
// now checks changed files in the background and nudges the model before
|
||
// its next request instead (see async_diagnostics.go). The tools' inline
|
||
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// A command that explicitly requests additional sandbox permissions
|
||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||
// must consent to, even when the base command's sandbox decision was Allow.
|
||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||
// dropped it, deadlocking the run. Keep this consistent with
|
||
// shouldRequestPermission's own condition.
|
||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||
action = PermissionActionPrompt
|
||
}
|
||
|
||
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(
|
||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||
"omit sandbox_permissions entirely and retry",
|
||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||
}
|
||
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 of network or file_system, for example " +
|
||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||
}
|
||
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) {
|
||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||
}
|
||
|
||
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||
// swarm tools un-defer once a swarm is active). Only the expensive part —
|
||
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
|
||
// tool's advertised name/description/schema is stable for the run. defCache nil
|
||
// disables caching (used by tests and the plain partitionTools entrypoint).
|
||
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]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
|
||
|
||
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||
// and byte-identical to the pre-deferral output.
|
||
if !active {
|
||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||
for _, tool := range visible {
|
||
if tool.Name() == tools.ToolSearchToolName {
|
||
continue
|
||
}
|
||
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
|
||
}
|
||
sort.Slice(definitions, func(left int, right int) bool {
|
||
return definitions[left].Name < definitions[right].Name
|
||
})
|
||
return definitions, ""
|
||
}
|
||
|
||
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||
// deferred tool loads mid-session. The tools block is part of the provider's
|
||
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||
// through the system prompt and messages — on EVERY load. Previously the whole
|
||
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||
// middle and shifted every later definition. Instead we build three append-only
|
||
// regions:
|
||
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||
// 2. tool_search — always present, right after the stable block;
|
||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||
// transition grows the tail instead of inserting into the eager block.
|
||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||
var hiddenTools []tools.Tool
|
||
for _, tool := range visible {
|
||
name := tool.Name()
|
||
if name == tools.ToolSearchToolName {
|
||
continue // added explicitly between the eager block and the loaded tail
|
||
}
|
||
if tools.IsDeferred(tool) {
|
||
if loaded[name] {
|
||
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||
} else {
|
||
hiddenTools = append(hiddenTools, tool)
|
||
}
|
||
continue
|
||
}
|
||
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
|
||
}
|
||
sort.Slice(eager, func(left int, right int) bool {
|
||
return eager[left].Name < eager[right].Name
|
||
})
|
||
sort.Slice(loadedTail, func(left int, right int) bool {
|
||
return loadedTail[left].Name < loadedTail[right].Name
|
||
})
|
||
|
||
discovery := ""
|
||
if len(hiddenTools) > 0 {
|
||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||
}
|
||
|
||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||
// eager block and carries the discovery list for still-hidden tools.
|
||
description := loader.Description()
|
||
if discovery != "" {
|
||
description = discovery
|
||
}
|
||
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||
definitions = append(definitions, eager...)
|
||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||
Name: loader.Name(),
|
||
Description: description,
|
||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||
})
|
||
definitions = append(definitions, loadedTail...)
|
||
|
||
return definitions, discovery
|
||
}
|
||
|
||
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
|
||
// cached render when defCache holds one for this tool name. A tool's advertised
|
||
// definition is stable across a run, so caching skips the recursive schema→map
|
||
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
|
||
// turn. tool_search is excluded by its callers (its description is dynamic), so it
|
||
// never poisons the cache. A nil cache computes fresh.
|
||
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
|
||
if defCache == nil {
|
||
return runtimeToolDefinition(tool)
|
||
}
|
||
if def, ok := defCache[tool.Name()]; ok {
|
||
return def
|
||
}
|
||
def := runtimeToolDefinition(tool)
|
||
defCache[tool.Name()] = def
|
||
return def
|
||
}
|
||
|
||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||
// JSON-schema parameters) as sent to the provider.
|
||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||
return zeroruntime.ToolDefinition{
|
||
Name: tool.Name(),
|
||
Description: tool.Description(),
|
||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|