// This file implements the three-phase tool execution (US-004): prepare → // execute → finalize, with the beforeToolCall / afterToolCall hooks. It mirrors // pi's agent-loop tool handling: a tool call is looked up in the registry, its // arguments are (optionally) prepared and schema-validated, the beforeToolCall // hook may block it, the tool runs (streaming partial updates), and the // afterToolCall hook may override the result field-by-field (no deep merge). // // Every failure mode (unknown tool, validation failure, block, abort, tool // error/panic) is turned into an error tool result rather than a Go error, so // the loop always has a ToolResultMessage to feed back to the model. package agenttool import ( "context" "encoding/json" "fmt" "strings" "github.com/smallnest/pigo/internal/agentcore" ) // toolResultMaxBytes is the executor-layer budget for a single tool result's // combined text, applied uniformly to EVERY tool right before its result enters // the AgentToolResult / message list. Individual tools also impose their own, // stricter inner caps (read: readToolMaxLines, search: searchMaxResults, // webfetch: webFetchMaxBytes, bash: bashMaxOutputBytes); those still run first // and clip a tool below this outer budget. This budget is the last line of // defense so a tool with no (or a looser) inner cap cannot blow the model's // context. Override per-executor via ToolExecutorConfig.MaxResultBytes. const toolResultMaxBytes = 100_000 // ToolExecutorConfig holds the registry and the optional per-phase hooks. Every // hook is optional (nil = default behavior). type ToolExecutorConfig struct { Registry *ToolRegistry PrepareArguments agentcore.PrepareArgumentsFunc BeforeToolCall agentcore.BeforeToolCallFunc AfterToolCall agentcore.AfterToolCallFunc // MaxResultBytes overrides the executor-layer per-result text budget. Zero // (the default) uses toolResultMaxBytes; a negative value disables the // budget entirely. MaxResultBytes int // MaxToolRetries overrides the number of RETRIES for a transient tool error // (see isRetryableToolError). Zero (the default) uses maxToolRetries; a // negative value disables retrying (a single attempt). Mirrors the // MaxResultBytes sentinel convention. MaxToolRetries int } // executeToolCall runs one tool call through prepare → execute → finalize and // returns the resulting ToolResultMessage plus whether the batch should // terminate. emit may be nil (no events). It never returns a Go error: every // failure is encoded into the returned message with IsError=true. func executeToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall, emit agentcore.EmitFunc) (agentcore.ToolResultMessage, bool) { // 1. prepare: lookup, prepareArguments, validate, beforeToolCall. tool, args, prep, isError := prepareToolCall(ctx, cfg, call) if prep != nil { // Prepare short-circuited (unknown tool / prepare error / validation / // block / abort): finalize the error result without executing. return finalizeToolCall(ctx, cfg, call, *prep, isError, emit) } // 2. execute. if emit != nil { if err := emit(ctx, agentcore.ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: call.Name, Args: args}); err != nil { return errorToolResult(call, "aborted before execution: "+err.Error()), false } } result, isError := runToolWithRetry(ctx, cfg, tool, call, args, emit) // 3. finalize: afterToolCall overrides. return finalizeToolCall(ctx, cfg, call, result, isError, emit) } // prepareToolCall performs the prepare phase. On success it returns the tool and // the (possibly rewritten) arguments with a nil result. On any short-circuit it // returns a non-nil *AgentToolResult and the isError flag. func prepareToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall) (agentcore.AgentTool, json.RawMessage, *agentcore.AgentToolResult, bool) { if ctx.Err() != nil { r := errorResult(fmt.Sprintf("tool %q aborted before execution", call.Name)) return nil, nil, &r, true } // Registry lookup. tool, ok := cfg.Registry.Get(call.Name) if !ok { r := errorResult(fmt.Sprintf("unknown tool %q", call.Name)) return nil, nil, &r, true } // prepareArguments (optional). args := call.Arguments if cfg.PrepareArguments != nil { prepared, err := cfg.PrepareArguments(ctx, call.Name, args) if err != nil { r := errorResult(fmt.Sprintf("prepareArguments for %q failed: %v", call.Name, err)) return nil, nil, &r, true } args = prepared } // JSON Schema validation. if errs := cfg.Registry.Validate(call.Name, args); len(errs) > 0 { r := ValidationErrorResult(call.Name, errs) return nil, nil, &r, true } // beforeToolCall hook (may block or rewrite arguments). if cfg.BeforeToolCall != nil { if dec := cfg.BeforeToolCall(ctx, agentcore.AgentToolCall{ID: call.ID, Name: call.Name, Arguments: args}); dec != nil { if dec.Block { r := agentcore.AgentToolResult{} if dec.Content != nil { r.Content = *dec.Content } else { r.Content = agentcore.ContentList{agentcore.NewTextContent(fmt.Sprintf("tool %q blocked by beforeToolCall", call.Name))} } if dec.Details != nil { r.Details = *dec.Details } return nil, nil, &r, true } // Argument rewrite (PreToolUse updatedInput): replace and re-validate // so a hook cannot smuggle schema-invalid args past the tool. if len(dec.UpdatedInput) > 0 { args = dec.UpdatedInput if errs := cfg.Registry.Validate(call.Name, args); len(errs) > 0 { r := ValidationErrorResult(call.Name, errs) return nil, nil, &r, true } } } } return tool, args, nil, false } // runToolWithRetry wraps runTool with the classified, bounded retry policy at // the single tool-execution seam so EVERY tool gets uniform resilience. It only // retries when runTool surfaces a non-nil Go error (transport/agent-error path) // AND isRetryableToolError says that error is transient; a (result, nil) is // done regardless of the result's IsError flag (a tool's own terminal result is // never retried). Retries are capped by toolRetryCap and separated by a small // backoff. Context cancellation short-circuits immediately: a cancelled/expired // outer ctx is never retried. func runToolWithRetry(ctx context.Context, cfg ToolExecutorConfig, tool agentcore.AgentTool, call agentcore.AgentToolCall, args json.RawMessage, emit agentcore.EmitFunc) (agentcore.AgentToolResult, bool) { retryCap := toolRetryCap(cfg.MaxToolRetries) var lastResult agentcore.AgentToolResult var lastIsError bool for attempt := 0; attempt <= retryCap; attempt++ { result, err, isError := runTool(ctx, tool, call, args, emit) if err == nil { // Execute returned (result, nil): terminal success regardless of // the result's own IsError flag. Done, no retry. return result, isError } lastResult, lastIsError = result, isError // Do not retry if the outer context is done (Canceled or its deadline // has passed) — a dead context means stop. if ctx.Err() != nil { break } // Only transient errors are retried, and only if we have budget left. if attempt >= retryCap || !isRetryableToolError(err) { break } // Small backoff; abort the wait early if ctx dies mid-sleep. if !waitToolRetryBackoff(ctx, attempt) { break } } return lastResult, lastIsError } // runTool executes the tool, recovering a panic into an error. It returns the // shaped error result, the raw error (nil on success), and the isError flag. // The raw error is surfaced so the caller can classify it for retry; on success // err is nil even if the result itself carries IsError semantics. func runTool(ctx context.Context, tool agentcore.AgentTool, call agentcore.AgentToolCall, args json.RawMessage, emit agentcore.EmitFunc) (result agentcore.AgentToolResult, rawErr error, isError bool) { defer func() { if r := recover(); r != nil { result = errorResult(fmt.Sprintf("tool %q panicked: %v", call.Name, r)) rawErr = toolPanic{value: r} isError = true } }() onUpdate := func(partial agentcore.AgentToolResult) { if emit == nil { return } _ = emit(ctx, agentcore.ToolExecutionUpdateEvent{ToolCallID: call.ID, ToolName: call.Name, PartialResult: partial}) } res, err := tool.Execute(ctx, call.ID, args, onUpdate) if err != nil { return errorResult(fmt.Sprintf("tool %q failed: %v", call.Name, err)), err, true } return res, nil, false } // finalizeToolCall applies the afterToolCall hook (field-level override, no deep // merge), emits the tool_execution_end event, and builds the ToolResultMessage. // It returns the message and whether this result requests termination. func finalizeToolCall(ctx context.Context, cfg ToolExecutorConfig, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool, emit agentcore.EmitFunc) (agentcore.ToolResultMessage, bool) { if cfg.AfterToolCall != nil { if ov := cfg.AfterToolCall(ctx, call, result, isError); ov != nil { if ov.Content != nil { result.Content = *ov.Content } if ov.Details != nil { result.Details = *ov.Details } if ov.Terminate != nil { result.Terminate = ov.Terminate } if ov.IsError != nil { isError = *ov.IsError } } } // Result-shaping seam: every tool's output funnels through here before it // becomes a ToolResultMessage, so this is the single point where the // executor-layer byte budget is enforced uniformly for ALL tools. result.Content = clipToolResultContent(result.Content, cfg.MaxResultBytes) if emit != nil { _ = emit(ctx, agentcore.ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: call.Name, Result: result, IsError: isError}) } terminate := result.Terminate != nil && *result.Terminate return agentcore.ToolResultMessage{ RoleField: agentcore.RoleToolResult, ToolCallID: call.ID, ToolName: call.Name, Content: result.Content, Details: result.Details, IsError: isError, }, terminate } // errorResult builds an error AgentToolResult carrying a single text block. func errorResult(msg string) agentcore.AgentToolResult { return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(msg)}} } // clipToolResultContent enforces the executor-layer byte budget on a tool // result's text, uniformly for every tool. budget<=0 with the sentinel meaning: // 0 => toolResultMaxBytes default, <0 => disabled. Non-text blocks (e.g. images) // pass through untouched and keep their order; the combined text of all text // blocks is measured against the budget and, when over, collapsed into a single // truncated text block via truncateToBudget (head + "[truncated N bytes]" + // tail, matching the bash idiom). Per-tool inner caps have already run, so this // only bites when a tool's own cap is looser or absent. func clipToolResultContent(content agentcore.ContentList, cfgMax int) agentcore.ContentList { budget := cfgMax if budget == 0 { budget = toolResultMaxBytes } if budget < 0 { return content } total := 0 textBlocks := 0 for _, c := range content { if t, ok := c.(agentcore.TextContent); ok { total += len(t.Text) textBlocks++ } } if textBlocks == 0 || total <= budget { return content } // Over budget: gather all text (in order) and non-text blocks separately, // then emit the non-text blocks followed by one truncated text block. var sb strings.Builder out := make(agentcore.ContentList, 0, len(content)) for _, c := range content { if t, ok := c.(agentcore.TextContent); ok { sb.WriteString(t.Text) continue } out = append(out, c) } out = append(out, agentcore.NewTextContent(truncateToBudget(sb.String(), budget))) return out } // truncateToBudget caps s at budget bytes. When s is longer it keeps a head and // a tail preview (split evenly) joined by a "[truncated N bytes]" marker, so // both the start and the end of the text survive. Cut points are pulled back to // UTF-8 rune boundaries so no partial rune is emitted; N counts the raw bytes // dropped from the middle. This is the single shared truncation idiom reused by // both the bash tool's inner cap and the executor-layer budget. func truncateToBudget(s string, budget int) string { if budget <= 0 || len(s) <= budget { return s } half := budget / 2 head := trimUTF8Prefix(s[:half]) tail := trimUTF8Suffix(s[len(s)-half:]) removed := len(s) - len(head) - len(tail) return head + fmt.Sprintf("\n[truncated %d bytes]\n", removed) + tail } // decodeArgs unmarshals a tool's JSON arguments into T. On failure it returns an // error result already shaped as ": invalid arguments: ...", so a tool's // Execute can decode and bail in one line: // // a, bad := decodeArgs[readToolArgs](args, "read") // if bad != nil { // return *bad, nil // } // // The ok flag distinguishes the failure case without comparing the zero value. func decodeArgs[T any](args json.RawMessage, tool string) (T, *agentcore.AgentToolResult) { var a T if err := json.Unmarshal(args, &a); err != nil { res := errorResult(fmt.Sprintf("%s: invalid arguments: %v", tool, err)) return a, &res } return a, nil } // errorToolResult builds an error ToolResultMessage directly (used when a call // is aborted outside the normal finalize path). func errorToolResult(call agentcore.AgentToolCall, msg string) agentcore.ToolResultMessage { return agentcore.ToolResultMessage{ RoleField: agentcore.RoleToolResult, ToolCallID: call.ID, ToolName: call.Name, Content: agentcore.ContentList{agentcore.NewTextContent(msg)}, IsError: true, } }