Refactor tool output format
This commit is contained in:
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -173,22 +175,68 @@ func (r *Registry) ToAPI() []APITool {
|
||||
return tools
|
||||
}
|
||||
|
||||
// ToolResult holds the structured output from a tool execution.
|
||||
type ToolResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ProcessState *os.ProcessState
|
||||
}
|
||||
|
||||
// FormatResult formats the tool result using the structured label-based
|
||||
// format with stdout, stderr, and exit code sections.
|
||||
// FormatResult formats the tool result using the structured label-based
|
||||
// format with stdout, stderr, and exit code sections.
|
||||
func (r *ToolResult) FormatResult() string {
|
||||
var buf strings.Builder
|
||||
|
||||
fmt.Fprintln(&buf, "▸ stdout:")
|
||||
if r.Stdout != "" {
|
||||
fmt.Fprint(&buf, r.Stdout)
|
||||
if !strings.HasSuffix(r.Stdout, "\n") {
|
||||
fmt.Fprint(&buf, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(&buf, "▸ stderr:")
|
||||
if r.Stderr != "" {
|
||||
fmt.Fprint(&buf, r.Stderr)
|
||||
if !strings.HasSuffix(r.Stderr, "\n") {
|
||||
fmt.Fprint(&buf, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
exitCode := r.ProcessState.ExitCode()
|
||||
fmt.Fprintf(&buf, "┗ exit: %d", exitCode)
|
||||
// Include signal info when the process was killed by a signal.
|
||||
if exitCode == -1 {
|
||||
fmt.Fprintf(&buf, " (%s)", r.ProcessState.String())
|
||||
}
|
||||
fmt.Fprintln(&buf)
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Execute runs a tool by name with the provided JSON arguments.
|
||||
// It expands argument templates, executes the command as a subprocess, and
|
||||
// returns stdout on success. The context can be used for cancellation;
|
||||
// tool-specific timeouts are applied on top of any context deadline.
|
||||
// returns a structured result with stdout, stderr, and exit code.
|
||||
// The context can be used for cancellation; tool-specific timeouts are
|
||||
// applied on top of any context deadline.
|
||||
//
|
||||
// Start failures (process never ran) return an error directly.
|
||||
func (r *Registry) Execute(
|
||||
ctx context.Context, name string, args string,
|
||||
) (string, error) {
|
||||
) (*ToolResult, error) {
|
||||
tool, ok := r.tools[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown tool: %s", name)
|
||||
return nil, fmt.Errorf("unknown tool: %q", name)
|
||||
}
|
||||
|
||||
// Evaluate argument templates.
|
||||
cmdArgs, err := tool.ParseArguments(args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse arguments: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"tool %q: parse arguments: %w", name, err,
|
||||
)
|
||||
}
|
||||
|
||||
// If defined, use the timeout.
|
||||
@@ -207,18 +255,63 @@ func (r *Registry) Execute(
|
||||
)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded && tool.timeout > 0 {
|
||||
return "", fmt.Errorf(
|
||||
"tool %s timed out after %v",
|
||||
name, tool.timeout,
|
||||
)
|
||||
err = cmd.Run()
|
||||
|
||||
// Extract exit code and handle errors.
|
||||
// After cmd.Run() returns, the possible error cases are:
|
||||
//
|
||||
// 1. Context error (ctx.Err() != nil): the context was cancelled or
|
||||
// timed out. If the process had started, it was killed and
|
||||
// ProcessState is set. If it had not, Process is nil.
|
||||
//
|
||||
// 2. Start failure (cmd.Process == nil): the executable was not found,
|
||||
// the fork failed, or the context was already done before Start.
|
||||
// No stdout/stderr to capture.
|
||||
//
|
||||
// 3. Process exited with error (err is *exec.ExitError): the process
|
||||
// ran but exited with a non-zero status or was killed by a signal.
|
||||
// ProcessState is set and contains the exit code.
|
||||
//
|
||||
// In all cases where cmd.Process != nil, ProcessState is guaranteed
|
||||
// to be set because Start() succeeded.
|
||||
if err != nil {
|
||||
switch {
|
||||
// Context was cancelled or timed out. The process was killed if
|
||||
// it had started. Distinguish tool timeout from other context
|
||||
// errors so we can return an appropriate message.
|
||||
case ctx.Err() != nil:
|
||||
if ctx.Err() == context.DeadlineExceeded &&
|
||||
tool.timeout > 0 {
|
||||
// Tool-specific timeout. Return a clear
|
||||
// message with the configured duration.
|
||||
return nil, fmt.Errorf(
|
||||
"tool %q: timed out after %v",
|
||||
name, tool.timeout,
|
||||
)
|
||||
}
|
||||
// Context cancelled or parent deadline exceeded.
|
||||
return nil, fmt.Errorf("tool %q: %w", name, ctx.Err())
|
||||
|
||||
// Process never started. No ProcessState, no output.
|
||||
// The error is the raw start failure (e.g., *PathError).
|
||||
case cmd.Process == nil:
|
||||
return nil, fmt.Errorf("tool %q: %w", name, err)
|
||||
|
||||
// Process ran and exited with non-zero status or was killed by a signal.
|
||||
// ProcessState is set; return it along with captured output.
|
||||
default:
|
||||
return &ToolResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ProcessState: cmd.ProcessState,
|
||||
}, nil
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"tool %s: %w\nstderr: %s",
|
||||
name, err, stderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
// No error. Process exited with status 0.
|
||||
return &ToolResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ProcessState: cmd.ProcessState,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user