mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
cli: --reasoning-effort notice evaluates the EFFECTIVE model (works without --model) (MED); unsafe warning fires for --auto high too (MED); --auto value validated even with --skip-permissions-unsafe (LOW); DefaultRegistry built once per exec, not 3x (LOW); --profile kept as intentional legacy-compat accept-and-ignore (contested finding resolved, doc-commented). modelregistry: removed dead FallbackFor. tools: removed dead TaskNonInteractiveMessage + dead OnSandboxDecision hook (no setter anywhere); fixed the now-stale loop.go comment. TDD where behavioral; build/vet/full-suite green. Refs #102 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
967 lines
34 KiB
Go
967 lines
34 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
const defaultSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
|
|
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
|
|
|
|
// 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."
|
|
|
|
// maxTaskDepth caps sub-agent (task) recursion. The top-level run is Depth 0, so
|
|
// with maxTaskDepth = 2 a parent (0) may spawn a child (1) which may spawn a
|
|
// grandchild (2); a task call AT Depth 2 is refused. This bounds the worst-case
|
|
// fan-out and the call stack without forbidding useful one- or two-level
|
|
// delegation.
|
|
const maxTaskDepth = 2
|
|
|
|
// maxTaskDepthAnswer is returned as the task tool result when the depth guard
|
|
// trips, so the model gets a clear, actionable message instead of a silent stop.
|
|
const maxTaskDepthAnswer = "Error: max sub-agent depth reached; cannot spawn another sub-agent. Complete this work directly."
|
|
|
|
// confirmationPolicy is the de-branded safety policy appended to the system
|
|
// prompt so the model self-polices before risky actions. It mirrors the
|
|
// sandbox's enforced rules but applies model-side judgement first.
|
|
//
|
|
//go:embed confirmation_policy.md
|
|
var confirmationPolicy string
|
|
|
|
// buildSystemPrompt returns the base instruction with the confirmation policy
|
|
// appended. It is built once per run so every turn shares the same system turn.
|
|
func buildSystemPrompt() string {
|
|
policy := strings.TrimSpace(confirmationPolicy)
|
|
if policy == "" {
|
|
return defaultSystemPrompt
|
|
}
|
|
return defaultSystemPrompt + "\n\n" + policy
|
|
}
|
|
|
|
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
|
|
if provider == nil {
|
|
return Result{}, errors.New("agent provider is required")
|
|
}
|
|
|
|
// Inject the provider so executeToolCall can spawn a sub-agent (task) child
|
|
// run with the same provider, without changing Run's signature or callers.
|
|
// A caller that explicitly set Provider (e.g. a custom child provider) wins.
|
|
if options.Provider == nil {
|
|
options.Provider = provider
|
|
}
|
|
|
|
maxTurns := options.MaxTurns
|
|
if maxTurns <= 0 {
|
|
maxTurns = 12
|
|
}
|
|
|
|
registry := options.Registry
|
|
if registry == nil {
|
|
registry = tools.NewRegistry()
|
|
}
|
|
|
|
permissionMode := options.PermissionMode
|
|
if permissionMode == "" {
|
|
permissionMode = PermissionModeAuto
|
|
}
|
|
|
|
messages := zeroruntime.SeedMessages(buildSystemPrompt(), prompt)
|
|
|
|
guards := newGuardState()
|
|
compactor := newCompactionState(options)
|
|
|
|
result := Result{Messages: copyMessages(messages)}
|
|
for turn := 0; turn < maxTurns; turn++ {
|
|
result.Turns = turn + 1
|
|
|
|
// 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)
|
|
|
|
request := zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: toolDefinitions(registry, permissionMode, options),
|
|
}
|
|
|
|
stream, err := provider.StreamCompletion(ctx, request)
|
|
if err != nil {
|
|
// 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, err.Error()); retried {
|
|
messages = compacted
|
|
if retryErr != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, retryErr
|
|
}
|
|
request = zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: toolDefinitions(registry, permissionMode, options),
|
|
}
|
|
stream, err = provider.StreamCompletion(ctx, request)
|
|
}
|
|
if err != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
|
|
OnText: options.OnText,
|
|
OnUsage: options.OnUsage,
|
|
})
|
|
if collected.Error != "" {
|
|
// REACTIVE compaction: the streamed error may also be a context
|
|
// limit (some providers surface it mid-stream). Compact and retry
|
|
// the same turn once before giving up.
|
|
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, collected.Error); retried {
|
|
messages = compacted
|
|
if retryErr != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, retryErr
|
|
}
|
|
retryRequest := zeroruntime.CompletionRequest{
|
|
Messages: copyMessages(messages),
|
|
Tools: toolDefinitions(registry, permissionMode, options),
|
|
}
|
|
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest)
|
|
if retryStreamErr != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, retryStreamErr
|
|
}
|
|
// Omit OnText on the reactive retry: when the original error
|
|
// surfaced MID-stream, partial text was already forwarded to the
|
|
// user. Re-streaming the retried response on top of it would
|
|
// duplicate output. OnUsage IS kept so token telemetry/budgeting
|
|
// still counts the successful retry. The retried text is captured
|
|
// in collected.Text and becomes the turn's assistant message.
|
|
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
|
|
OnUsage: options.OnUsage,
|
|
})
|
|
}
|
|
}
|
|
if collected.Error != "" {
|
|
result.Messages = copyMessages(messages)
|
|
return result, errors.New(collected.Error)
|
|
}
|
|
if ctx.Err() != nil {
|
|
result.Messages = copyMessages(messages)
|
|
return result, ctx.Err()
|
|
}
|
|
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleAssistant,
|
|
Content: collected.Text,
|
|
ToolCalls: collected.ToolCalls,
|
|
})
|
|
|
|
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
|
|
}
|
|
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 := ""
|
|
for index, call := range collected.ToolCalls {
|
|
if options.OnToolCall != nil {
|
|
options.OnToolCall(call)
|
|
}
|
|
toolResult := executeToolCall(ctx, registry, call, permissionMode, options)
|
|
if options.OnToolResult != nil {
|
|
options.OnToolResult(toolResult)
|
|
}
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleTool,
|
|
Content: toolResult.Output,
|
|
ToolCallID: toolResult.ToolCallID,
|
|
})
|
|
|
|
// 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.
|
|
outcome := guards.observeToolResult(call.Name, toolResult.Status == tools.StatusError, 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)
|
|
}
|
|
}
|
|
|
|
// 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.planReminder(result.Turns); reminder != "" {
|
|
messages = append(messages, zeroruntime.Message{
|
|
Role: zeroruntime.MessageRoleUser,
|
|
Content: reminder,
|
|
})
|
|
}
|
|
}
|
|
|
|
result.FinalAnswer = maxTurnsAnswer
|
|
result.Messages = copyMessages(messages)
|
|
return result, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) ToolResult {
|
|
args := map[string]any{}
|
|
if call.Arguments != "" {
|
|
if err := json.Unmarshal([]byte(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(),
|
|
}
|
|
}
|
|
}
|
|
if !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.`,
|
|
}
|
|
}
|
|
|
|
// 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, options)
|
|
}
|
|
|
|
// task is intercepted here too: a normal tool's Run() has no access to the
|
|
// provider/registry needed to spawn a child agent run. When a provider is
|
|
// available and we are under the depth cap we run a synchronous sub-agent;
|
|
// otherwise it falls through to the tool's own graceful Run() (e.g. the
|
|
// "sub-agents are unavailable" fallback or the depth-limit error).
|
|
if call.Name == "task" {
|
|
return executeTask(ctx, registry, call, args, permissionMode, options)
|
|
}
|
|
|
|
tool, toolFound := registry.Get(call.Name)
|
|
permissionGranted := permissionMode == PermissionModeUnsafe
|
|
if toolFound && tool.Safety().Permission == tools.PermissionAllow {
|
|
permissionGranted = true
|
|
}
|
|
|
|
var preflightDecision *sandbox.Decision
|
|
if toolFound && options.Sandbox != nil {
|
|
decision := options.Sandbox.Evaluate(ctx, sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
|
preflightDecision = &decision
|
|
}
|
|
|
|
decisionReason := ""
|
|
if toolFound && options.OnPermissionRequest != nil && shouldRequestPermission(tool, 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)
|
|
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)
|
|
switch decision.Action {
|
|
case PermissionDecisionAllow:
|
|
permissionGranted = true
|
|
case PermissionDecisionAlwaysAllow:
|
|
permissionGranted = true
|
|
grant, err := persistPermissionGrant(call.Name, decisionReason, options)
|
|
if err != nil {
|
|
emitDeniedPermission(options, call, requestEvent, "failed to persist permission grant: "+err.Error())
|
|
return deniedPermissionResult(call, "failed to persist permission grant: "+err.Error(), requestEvent)
|
|
}
|
|
requestEvent.GrantMatched = true
|
|
requestEvent.Grant = &grant
|
|
default:
|
|
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
|
return deniedPermissionResult(call, decisionReason, requestEvent)
|
|
}
|
|
}
|
|
|
|
result := registry.RunWithOptions(ctx, call.Name, args, tools.RunOptions{
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: string(permissionMode),
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
// The sandbox decision (if any) is returned synchronously on the Result and
|
|
// used here for permission event building.
|
|
})
|
|
sandboxDecision := result.SandboxDecision
|
|
if toolFound && options.OnPermission != nil {
|
|
if event, ok := buildPermissionEvent(call, tool, args, permissionGranted, permissionMode, options, sandboxDecision); ok {
|
|
event.DecisionReason = decisionReason
|
|
options.OnPermission(event)
|
|
}
|
|
}
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// executeTask spawns a synchronous sub-agent run for a task tool call. It is the
|
|
// task-tool counterpart to executeAskUser: the loop intercepts the call because
|
|
// a normal tool's Run() cannot reach the provider/registry needed to start a
|
|
// child run. The child shares ctx (so cancellation propagates) and inherits the
|
|
// permission mode and sandbox, but runs with NO interactive callbacks — a
|
|
// sub-agent must never prompt the user or ask questions. Recursion is bounded by
|
|
// the depth guard and by stripping "task" from the child registry.
|
|
func executeTask(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) ToolResult {
|
|
request, err := tools.ParseTaskRequest(args)
|
|
if err != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Invalid arguments for task: " + err.Error(),
|
|
}
|
|
}
|
|
|
|
// No nil-provider fallback here: Run() rejects a nil provider and injects it
|
|
// into options before the loop runs, so options.Provider is always non-nil by
|
|
// the time executeTask is reached. (Unlike ask_user, which genuinely degrades
|
|
// when OnAskUser is nil, the task path has no headless degradation to fall
|
|
// back to.)
|
|
|
|
// Depth guard: refuse to spawn beyond the cap so a sub-agent can't recurse
|
|
// indefinitely. This is an error result (not a hard loop stop) so the parent
|
|
// model can recover by doing the work itself.
|
|
if options.Depth >= maxTaskDepth {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: maxTaskDepthAnswer,
|
|
}
|
|
}
|
|
|
|
// Build the child run's options from the parent's, then isolate it:
|
|
// - Depth+1 so the guard counts nesting.
|
|
// - Registry without "task" (no infinite nesting) and "ask_user" (a
|
|
// sub-agent has no interactive user to prompt).
|
|
// - Interactive/activity callbacks cleared so the sub-run is fully headless.
|
|
// - OnUsage IS propagated: it is non-interactive cost telemetry, not
|
|
// user-facing tool activity, so the child's real token spend must roll up
|
|
// to whoever tracks cost on the parent run.
|
|
// Permission mode + sandbox + autonomy are inherited so the child enforces
|
|
// the same safety policy.
|
|
childRegistry := options.Registry
|
|
if childRegistry == nil {
|
|
childRegistry = registry
|
|
}
|
|
if childRegistry != nil {
|
|
childRegistry = childRegistry.Without("task", "ask_user")
|
|
}
|
|
|
|
childOptions := Options{
|
|
MaxTurns: options.MaxTurns,
|
|
ContextWindow: options.ContextWindow,
|
|
CompactionPreserveLast: options.CompactionPreserveLast,
|
|
Provider: options.Provider,
|
|
Depth: options.Depth + 1,
|
|
Registry: childRegistry,
|
|
PermissionMode: permissionMode,
|
|
Autonomy: options.Autonomy,
|
|
Sandbox: options.Sandbox,
|
|
EnabledTools: options.EnabledTools,
|
|
DisabledTools: options.DisabledTools,
|
|
// OnUsage is intentionally propagated so a sub-agent's token spend is
|
|
// counted for cost accounting (cli/exec.go and tui/model.go record usage
|
|
// via OnUsage). It is telemetry, not interactive output.
|
|
OnUsage: options.OnUsage,
|
|
// Other interactive + observer callbacks (OnText, OnToolCall,
|
|
// OnToolResult, OnPermission*, OnAskUser) intentionally left nil: the
|
|
// sub-run must not prompt the user, ask questions, or surface its inner
|
|
// tool activity as if it were the parent's.
|
|
}
|
|
|
|
childResult, runErr := Run(ctx, request.Prompt, options.Provider, childOptions)
|
|
if runErr != nil {
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: sub-agent failed: " + runErr.Error(),
|
|
Display: tools.Display{Kind: "task", Summary: taskDisplaySummary(request, "failed")},
|
|
}
|
|
}
|
|
|
|
// Scrub the child's final answer through the same redaction boundary the
|
|
// registry applies to tool output, so a secret a sub-agent surfaced never
|
|
// lands in the parent transcript unredacted.
|
|
output, redacted := scrubInterceptedOutput(childResult.FinalAnswer)
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusOK,
|
|
Output: output,
|
|
Redacted: redacted,
|
|
Display: tools.Display{Kind: "task", Summary: taskDisplaySummary(request, "")},
|
|
}
|
|
}
|
|
|
|
// taskDisplaySummary builds a short, human-readable label for a task tool result.
|
|
func taskDisplaySummary(request tools.TaskRequest, suffix string) string {
|
|
label := request.Description
|
|
if label == "" {
|
|
label = "sub-agent"
|
|
}
|
|
summary := "ran sub-agent: " + label
|
|
if suffix != "" {
|
|
summary += " (" + suffix + ")"
|
|
}
|
|
return summary
|
|
}
|
|
|
|
// 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.
|
|
func executeAskUser(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any, options Options) ToolResult {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
if options.OnAskUser == nil {
|
|
return askUserFallbackResult(ctx, registry, call, args)
|
|
}
|
|
|
|
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 {
|
|
return askUserFallbackResult(ctx, registry, call, args)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// askUserFallbackResult runs the registered ask_user tool (or returns the shared
|
|
// graceful message) so the no-interactive-user path matches the headless path.
|
|
func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call ToolCall, args map[string]any) ToolResult {
|
|
if _, ok := registry.Get(call.Name); ok {
|
|
result := registry.Run(ctx, call.Name, args)
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: result.Status,
|
|
Output: result.Output,
|
|
Redacted: result.Redacted,
|
|
}
|
|
}
|
|
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,
|
|
Options: append([]string{}, question.Options...),
|
|
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),
|
|
Autonomy: sandbox.Autonomy(options.Autonomy),
|
|
Args: args,
|
|
Reason: safety.Reason,
|
|
}
|
|
}
|
|
|
|
func shouldRequestPermission(tool tools.Tool, permissionGranted bool, decision *sandbox.Decision) bool {
|
|
if permissionGranted || tool.Safety().Permission != tools.PermissionPrompt {
|
|
return false
|
|
}
|
|
if decision != nil {
|
|
return decision.Action == sandbox.ActionPrompt
|
|
}
|
|
return true
|
|
}
|
|
|
|
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, PermissionDecisionAlwaysAllow:
|
|
return action
|
|
default:
|
|
return PermissionDecisionDeny
|
|
}
|
|
}
|
|
|
|
func persistPermissionGrant(toolName string, reason string, options Options) (sandbox.Grant, error) {
|
|
if options.Sandbox == nil {
|
|
return sandbox.Grant{}, errors.New("sandbox engine is not configured")
|
|
}
|
|
maxAutonomy := sandbox.Autonomy(options.Autonomy)
|
|
if maxAutonomy == "" {
|
|
maxAutonomy = sandbox.AutonomyMedium
|
|
}
|
|
if normalized, err := sandbox.NormalizeAutonomy(maxAutonomy); err == nil {
|
|
maxAutonomy = normalized
|
|
}
|
|
return options.Sandbox.Grant(sandbox.GrantInput{
|
|
ToolName: toolName,
|
|
Decision: sandbox.GrantAllow,
|
|
MaxAutonomy: maxAutonomy,
|
|
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.PermissionGranted = false
|
|
event.DecisionReason = 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.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
|
|
}
|
|
return ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Status: tools.StatusError,
|
|
Output: "Error: Permission denied for " + call.Name + ": " + reason,
|
|
Meta: map[string]string{
|
|
"permission_action": string(event.Action),
|
|
},
|
|
}
|
|
}
|
|
|
|
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),
|
|
Autonomy: sandbox.Autonomy(options.Autonomy),
|
|
Args: args,
|
|
Reason: safety.Reason,
|
|
})
|
|
var violation *sandbox.Violation
|
|
grantMatched := false
|
|
var grant *sandbox.Grant
|
|
|
|
if decision != nil {
|
|
action = permissionActionFromSandbox(decision.Action)
|
|
if decision.Reason != "" {
|
|
reason = decision.Reason
|
|
}
|
|
risk = decision.Risk
|
|
violation = decision.Violation
|
|
grantMatched = decision.GrantMatched
|
|
grant = decision.Grant
|
|
} else {
|
|
switch safety.Permission {
|
|
case tools.PermissionDeny:
|
|
action = PermissionActionDeny
|
|
case tools.PermissionPrompt:
|
|
if permissionGranted {
|
|
action = PermissionActionAllow
|
|
} else {
|
|
action = PermissionActionPrompt
|
|
}
|
|
default:
|
|
return PermissionEvent{}, false
|
|
}
|
|
}
|
|
|
|
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && violation == nil {
|
|
return PermissionEvent{}, false
|
|
}
|
|
|
|
autonomy := options.Autonomy
|
|
if normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(autonomy)); err == nil {
|
|
autonomy = string(normalized)
|
|
}
|
|
|
|
return PermissionEvent{
|
|
ToolCallID: call.ID,
|
|
ToolName: call.Name,
|
|
Action: action,
|
|
Permission: string(safety.Permission),
|
|
PermissionGranted: permissionGranted,
|
|
PermissionMode: permissionMode,
|
|
Autonomy: autonomy,
|
|
SideEffect: string(safety.SideEffect),
|
|
Reason: reason,
|
|
Risk: risk,
|
|
Violation: violation,
|
|
GrantMatched: grantMatched,
|
|
Grant: grant,
|
|
}, true
|
|
}
|
|
|
|
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) 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,
|
|
Risk: event.Risk,
|
|
Args: cloneArgs(args),
|
|
Violation: event.Violation,
|
|
GrantMatched: event.GrantMatched,
|
|
Grant: event.Grant,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
|
|
registeredTools := registry.All()
|
|
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
|
|
for _, tool := range registeredTools {
|
|
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
|
|
continue
|
|
}
|
|
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
Name: tool.Name(),
|
|
Description: tool.Description(),
|
|
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
|
})
|
|
}
|
|
|
|
sort.Slice(definitions, func(left int, right int) bool {
|
|
return definitions[left].Name < definitions[right].Name
|
|
})
|
|
return definitions
|
|
}
|
|
|
|
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.Minimum != nil {
|
|
schema["minimum"] = *property.Minimum
|
|
}
|
|
if property.Maximum != nil {
|
|
schema["maximum"] = *property.Maximum
|
|
}
|
|
if property.Items != nil {
|
|
schema["items"] = propertyToRuntimeMap(*property.Items)
|
|
}
|
|
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 == PermissionModeAuto {
|
|
return tool.Safety().Permission == tools.PermissionAllow
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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...)
|
|
}
|
|
}
|
|
return copied
|
|
}
|