Files
2026-08-14 23:41:57 +08:00

342 lines
13 KiB
Go

// 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 "<tool>: 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,
}
}