llm: make Tool.Run return ToolOut
This is preliminary work towards
allowing tools to add additional information.
No functional changes (at least, that's the intent).
diff --git a/claudetool/about_sketch.go b/claudetool/about_sketch.go
index 15a4059..56b0b28 100644
--- a/claudetool/about_sketch.go
+++ b/claudetool/about_sketch.go
@@ -4,7 +4,6 @@
"context"
_ "embed"
"encoding/json"
- "fmt"
"log/slog"
"strings"
"text/template"
@@ -43,7 +42,7 @@
var aboutSketchTemplate = template.Must(template.New("sketch").Parse(aboutSketch))
-func aboutSketchRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func aboutSketchRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
slog.InfoContext(ctx, "about_sketch called")
info := conversation.ToolCallInfoFromContext(ctx)
@@ -58,7 +57,7 @@
}
buf := new(strings.Builder)
if err := aboutSketchTemplate.Execute(buf, dot); err != nil {
- return nil, fmt.Errorf("template execution error: %w", err)
+ return llm.ErrorfToolOut("template execution error: %w", err)
}
- return llm.TextContent(buf.String()), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(buf.String())}
}
diff --git a/claudetool/bash.go b/claudetool/bash.go
index 59b8e3c..741f1e9 100644
--- a/claudetool/bash.go
+++ b/claudetool/bash.go
@@ -141,22 +141,22 @@
}
}
-func (b *BashTool) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
var req bashInput
if err := json.Unmarshal(m, &req); err != nil {
- return nil, fmt.Errorf("failed to unmarshal bash command input: %w", err)
+ return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
}
// do a quick permissions check (NOT a security barrier)
err := bashkit.Check(req.Command)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Custom permission callback if set
if b.CheckPermission != nil {
if err := b.CheckPermission(req.Command); err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
}
@@ -174,23 +174,23 @@
if req.Background {
result, err := executeBackgroundBash(ctx, req, timeout)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Marshal the result to JSON
// TODO: emit XML(-ish) instead?
output, err := json.Marshal(result)
if err != nil {
- return nil, fmt.Errorf("failed to marshal background result: %w", err)
+ return llm.ErrorfToolOut("failed to marshal background result: %w", err)
}
- return llm.TextContent(string(output)), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(string(output))}
}
// For foreground commands, use executeBash
out, execErr := executeBash(ctx, req, timeout)
if execErr != nil {
- return nil, execErr
+ return llm.ErrorToolOut(execErr)
}
- return llm.TextContent(out), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(out)}
}
const maxBashOutputLength = 131072
@@ -435,7 +435,7 @@
},
"required": ["results"]
}`),
- Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
+ Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
type InstallResult struct {
CommandName string `json:"command_name"`
Installed bool `json:"installed"`
@@ -452,7 +452,7 @@
slog.InfoContext(ctx, "auto-tool installation complete", "results", results)
}
done = true
- return llm.TextContent(""), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("")}
},
}
diff --git a/claudetool/bash_test.go b/claudetool/bash_test.go
index 98410d5..8c57a4b 100644
--- a/claudetool/bash_test.go
+++ b/claudetool/bash_test.go
@@ -17,10 +17,11 @@
input := json.RawMessage(`{"command":"echo 'slow test'","slow_ok":true}`)
bashTool := (&BashTool{}).Tool()
- result, err := bashTool.Run(context.Background(), input)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := bashTool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
expected := "slow test\n"
if len(result) == 0 || result[0].Text != expected {
@@ -33,10 +34,11 @@
input := json.RawMessage(`{"command":"echo 'slow background test'","slow_ok":true,"background":true}`)
bashTool := (&BashTool{}).Tool()
- result, err := bashTool.Run(context.Background(), input)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := bashTool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// Should return background result JSON
var bgResult BackgroundResult
@@ -64,10 +66,11 @@
t.Run("Basic Command", func(t *testing.T) {
input := json.RawMessage(`{"command":"echo 'Hello, world!'"}`)
- result, err := tool.Run(context.Background(), input)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
expected := "Hello, world!\n"
if len(result) == 0 || result[0].Text != expected {
@@ -79,10 +82,11 @@
t.Run("Command With Arguments", func(t *testing.T) {
input := json.RawMessage(`{"command":"echo -n foo && echo -n bar"}`)
- result, err := tool.Run(context.Background(), input)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
expected := "foobar"
if len(result) == 0 || result[0].Text != expected {
@@ -104,10 +108,11 @@
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := tool.Run(context.Background(), inputJSON)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
expected := "Completed\n"
if len(result) == 0 || result[0].Text != expected {
@@ -130,11 +135,11 @@
input := json.RawMessage(`{"command":"sleep 0.5 && echo 'Should not see this'"}`)
- _, err := tool.Run(context.Background(), input)
- if err == nil {
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
t.Errorf("Expected timeout error, got none")
- } else if !strings.Contains(err.Error(), "timed out") {
- t.Errorf("Expected timeout error, got: %v", err)
+ } else if !strings.Contains(toolOut.Error.Error(), "timed out") {
+ t.Errorf("Expected timeout error, got: %v", toolOut.Error)
}
})
@@ -142,8 +147,8 @@
t.Run("Failed Command", func(t *testing.T) {
input := json.RawMessage(`{"command":"exit 1"}`)
- _, err := tool.Run(context.Background(), input)
- if err == nil {
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
t.Errorf("Expected error for failed command, got none")
}
})
@@ -152,8 +157,8 @@
t.Run("Invalid JSON Input", func(t *testing.T) {
input := json.RawMessage(`{"command":123}`) // Invalid JSON (command must be string)
- _, err := tool.Run(context.Background(), input)
- if err == nil {
+ toolOut := tool.Run(context.Background(), input)
+ if toolOut.Error == nil {
t.Errorf("Expected error for invalid input, got none")
}
})
@@ -268,10 +273,11 @@
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := tool.Run(context.Background(), inputJSON)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// Parse the returned JSON
var bgResult BackgroundResult
@@ -326,10 +332,11 @@
t.Fatalf("Failed to marshal input: %v", err)
}
- result, err := tool.Run(context.Background(), inputJSON)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// Parse the returned JSON
var bgResult BackgroundResult
@@ -384,10 +391,11 @@
}
// Start the command in the background
- result, err := tool.Run(context.Background(), inputJSON)
- if err != nil {
- t.Fatalf("Unexpected error: %v", err)
+ toolOut := tool.Run(context.Background(), inputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("Unexpected error: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// Parse the returned JSON
var bgResult BackgroundResult
diff --git a/claudetool/browse/browse.go b/claudetool/browse/browse.go
index e96a7f9..99f6ea7 100644
--- a/claudetool/browse/browse.go
+++ b/claudetool/browse/browse.go
@@ -182,19 +182,19 @@
}
}
-func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input navigateInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
if isPort80(input.URL) {
- return nil, fmt.Errorf("port 80 is not the port you're looking for--port 80 is the main sketch server")
+ return llm.ErrorToolOut(fmt.Errorf("port 80 is not the port you're looking for--port 80 is the main sketch server"))
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -206,10 +206,10 @@
chromedp.WaitReady("body"),
)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// ClickTool definition
@@ -246,15 +246,15 @@
}
}
-func (b *BrowseTools) clickRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) clickRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input clickInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -273,10 +273,10 @@
err = chromedp.Run(timeoutCtx, actions...)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// TypeTool definition
@@ -318,15 +318,15 @@
}
}
-func (b *BrowseTools) typeRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) typeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input typeInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -346,10 +346,10 @@
err = chromedp.Run(timeoutCtx, actions...)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// WaitForTool definition
@@ -381,15 +381,15 @@
}
}
-func (b *BrowseTools) waitForRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) waitForRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input waitForInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
@@ -397,10 +397,10 @@
err = chromedp.Run(timeoutCtx, chromedp.WaitReady(input.Selector))
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// GetTextTool definition
@@ -432,15 +432,15 @@
}
}
-func (b *BrowseTools) getTextRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) getTextRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input getTextInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -453,10 +453,10 @@
chromedp.Text(input.Selector, &text),
)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("<innerText>" + text + "</innerText>"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("<innerText>" + text + "</innerText>")}
}
// EvalTool definition
@@ -488,15 +488,15 @@
}
}
-func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input evalInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -506,16 +506,16 @@
var result any
err = chromedp.Run(timeoutCtx, chromedp.Evaluate(input.Expression, &result))
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Return the result as JSON
response, err := json.Marshal(result)
if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ return llm.ErrorfToolOut("failed to marshal response: %w", err)
}
- return llm.TextContent("<javascript_result>" + string(response) + "</javascript_result>"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("<javascript_result>" + string(response) + "</javascript_result>")}
}
// ScreenshotTool definition
@@ -552,15 +552,15 @@
}
}
-func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input screenshotInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -583,13 +583,13 @@
err = chromedp.Run(timeoutCtx, actions...)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Save the screenshot and get its ID for potential future reference
id := b.SaveScreenshot(buf)
if id == "" {
- return nil, fmt.Errorf("failed to save screenshot")
+ return llm.ErrorToolOut(fmt.Errorf("failed to save screenshot"))
}
// Get the full path to the screenshot
@@ -599,7 +599,7 @@
base64Data := base64.StdEncoding.EncodeToString(buf)
// Return the screenshot directly to the LLM
- return []llm.Content{
+ return llm.ToolOut{LLMContent: []llm.Content{
{
Type: llm.ContentTypeText,
Text: fmt.Sprintf("Screenshot taken (saved as %s)", screenshotPath),
@@ -609,7 +609,7 @@
MediaType: "image/png",
Data: base64Data,
},
- }, nil
+ }}
}
// ScrollIntoViewTool definition
@@ -641,15 +641,15 @@
}
}
-func (b *BrowseTools) scrollIntoViewRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) scrollIntoViewRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input scrollIntoViewInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -671,14 +671,14 @@
chromedp.Evaluate(script, &result),
)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
if !result {
- return nil, fmt.Errorf("element not found: %s", input.Selector)
+ return llm.ErrorToolOut(fmt.Errorf("element not found: %s", input.Selector))
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// ResizeTool definition
@@ -715,15 +715,15 @@
}
}
-func (b *BrowseTools) resizeRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) resizeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input resizeInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
browserCtx, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Create a timeout context for this operation
@@ -732,7 +732,7 @@
// Validate dimensions
if input.Width <= 0 || input.Height <= 0 {
- return nil, fmt.Errorf("invalid dimensions: width and height must be positive")
+ return llm.ErrorToolOut(fmt.Errorf("invalid dimensions: width and height must be positive"))
}
// Resize the browser window
@@ -740,10 +740,10 @@
chromedp.EmulateViewport(int64(input.Width), int64(input.Height)),
)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
- return llm.TextContent("done"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("done")}
}
// GetTools returns browser tools, optionally filtering out screenshot-related tools
@@ -824,34 +824,34 @@
}
}
-func (b *BrowseTools) readImageRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) readImageRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input readImageInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
// Check if the path exists
if _, err := os.Stat(input.Path); os.IsNotExist(err) {
- return nil, fmt.Errorf("image file not found: %s", input.Path)
+ return llm.ErrorfToolOut("image file not found: %s", input.Path)
}
// Read the file
imageData, err := os.ReadFile(input.Path)
if err != nil {
- return nil, fmt.Errorf("failed to read image file: %w", err)
+ return llm.ErrorfToolOut("failed to read image file: %w", err)
}
// Detect the image type
imageType := http.DetectContentType(imageData)
if !strings.HasPrefix(imageType, "image/") {
- return nil, fmt.Errorf("file is not an image: %s", imageType)
+ return llm.ErrorfToolOut("file is not an image: %s", imageType)
}
// Encode the image as base64
base64Data := base64.StdEncoding.EncodeToString(imageData)
// Create a Content object that includes both text and the image
- return []llm.Content{
+ return llm.ToolOut{LLMContent: []llm.Content{
{
Type: llm.ContentTypeText,
Text: fmt.Sprintf("Image from %s (type: %s)", input.Path, imageType),
@@ -861,7 +861,7 @@
MediaType: imageType,
Data: base64Data,
},
- }, nil
+ }}
}
// parseTimeout parses a timeout string and returns a time.Duration
@@ -916,16 +916,16 @@
}
}
-func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input recentConsoleLogsInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
// Ensure browser is initialized
_, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Apply limit (default to 100 if not specified)
@@ -947,7 +947,7 @@
// Format the logs as JSON
logData, err := json.MarshalIndent(logs, "", " ")
if err != nil {
- return nil, fmt.Errorf("failed to serialize logs: %w", err)
+ return llm.ErrorfToolOut("failed to serialize logs: %w", err)
}
// Format the logs
@@ -961,7 +961,7 @@
sb.WriteString(string(logData))
}
- return llm.TextContent(sb.String()), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(sb.String())}
}
// ClearConsoleLogsTool definition
@@ -977,16 +977,16 @@
}
}
-func (b *BrowseTools) clearConsoleLogsRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (b *BrowseTools) clearConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input clearConsoleLogsInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
// Ensure browser is initialized
_, err := b.GetBrowserContext()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Clear console logs with mutex protection
@@ -995,5 +995,5 @@
b.consoleLogs = make([]*runtime.EventConsoleAPICalled, 0)
b.consoleLogsMutex.Unlock()
- return llm.TextContent(fmt.Sprintf("Cleared %d console log entries.", logCount)), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(fmt.Sprintf("Cleared %d console log entries.", logCount))}
}
diff --git a/claudetool/browse/browse_test.go b/claudetool/browse/browse_test.go
index ff17055..d1b1eec 100644
--- a/claudetool/browse/browse_test.go
+++ b/claudetool/browse/browse_test.go
@@ -177,10 +177,11 @@
inputJSON, _ := json.Marshal(input)
// Call the tool
- result, err := navTool.Run(ctx, json.RawMessage(inputJSON))
- if err != nil {
- t.Fatalf("Error running navigate tool: %v", err)
+ toolOut := navTool.Run(ctx, json.RawMessage(inputJSON))
+ if toolOut.Error != nil {
+ t.Fatalf("Error running navigate tool: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// Verify the response is successful
resultText := result[0].Text
@@ -288,10 +289,11 @@
input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
// Run the tool
- result, err := readImageTool.Run(ctx, json.RawMessage(input))
- if err != nil {
- t.Fatalf("Read image tool failed: %v", err)
+ toolOut := readImageTool.Run(ctx, json.RawMessage(input))
+ if toolOut.Error != nil {
+ t.Fatalf("Read image tool failed: %v", toolOut.Error)
}
+ result := toolOut.LLMContent
// In the updated code, result is already a []llm.Content
contents := result
@@ -338,20 +340,22 @@
// Navigate to a simple page to ensure the browser is ready
navInput := json.RawMessage(`{"url": "about:blank"}`)
- content, err := tools.NewNavigateTool().Run(ctx, navInput)
- if err != nil {
- t.Fatalf("Navigation error: %v", err)
+ toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+ if toolOut.Error != nil {
+ t.Fatalf("Navigation error: %v", toolOut.Error)
}
+ content := toolOut.LLMContent
if !strings.Contains(content[0].Text, "done") {
t.Fatalf("Expected done in navigation response, got: %s", content[0].Text)
}
// Check default viewport dimensions via JavaScript
evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
- content, err = tools.NewEvalTool().Run(ctx, evalInput)
- if err != nil {
- t.Fatalf("Evaluation error: %v", err)
+ toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+ if toolOut.Error != nil {
+ t.Fatalf("Evaluation error: %v", toolOut.Error)
}
+ content = toolOut.LLMContent
// Parse the result to verify dimensions
var response struct {
@@ -398,30 +402,33 @@
// Resize to mobile dimensions
resizeTool := tools.NewResizeTool()
input := json.RawMessage(`{"width": 375, "height": 667}`)
- content, err := resizeTool.Run(ctx, input)
- if err != nil {
- t.Fatalf("Error: %v", err)
+ toolOut := resizeTool.Run(ctx, input)
+ if toolOut.Error != nil {
+ t.Fatalf("Error: %v", toolOut.Error)
}
+ content := toolOut.LLMContent
if !strings.Contains(content[0].Text, "done") {
t.Fatalf("Expected done in response, got: %s", content[0].Text)
}
// Navigate to a test page and verify using JavaScript to get window dimensions
navInput := json.RawMessage(`{"url": "https://example.com"}`)
- content, err = tools.NewNavigateTool().Run(ctx, navInput)
- if err != nil {
- t.Fatalf("Error: %v", err)
+ toolOut = tools.NewNavigateTool().Run(ctx, navInput)
+ if toolOut.Error != nil {
+ t.Fatalf("Error: %v", toolOut.Error)
}
+ content = toolOut.LLMContent
if !strings.Contains(content[0].Text, "done") {
t.Fatalf("Expected done in response, got: %s", content[0].Text)
}
// Check dimensions via JavaScript
evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
- content, err = tools.NewEvalTool().Run(ctx, evalInput)
- if err != nil {
- t.Fatalf("Error: %v", err)
+ toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+ if toolOut.Error != nil {
+ t.Fatalf("Error: %v", toolOut.Error)
}
+ content = toolOut.LLMContent
// The dimensions might not be exactly what we set (browser chrome, etc.)
// but they should be close
diff --git a/claudetool/codereview/codereview_test.go b/claudetool/codereview/codereview_test.go
index f9d240e..4bba851 100644
--- a/claudetool/codereview/codereview_test.go
+++ b/claudetool/codereview/codereview_test.go
@@ -234,10 +234,11 @@
// runDifferentialTest runs the code review tool on the repository and returns the result.
func runDifferentialTest(reviewer *CodeReviewer) (string, error) {
ctx := context.Background()
- result, err := reviewer.Run(ctx, nil)
- if err != nil {
- return "", fmt.Errorf("error running code review: %w", err)
+ toolOut := reviewer.Run(ctx, nil)
+ if toolOut.Error != nil {
+ return "", fmt.Errorf("error running code review: %w", toolOut.Error)
}
+ result := toolOut.LLMContent
// Normalize paths in the result
resultStr := ""
diff --git a/claudetool/codereview/differential.go b/claudetool/codereview/differential.go
index 764f000..6c29d06 100644
--- a/claudetool/codereview/differential.go
+++ b/claudetool/codereview/differential.go
@@ -48,14 +48,14 @@
return spec
}
-func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func (r *CodeReviewer) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
// Parse input to get timeout
var input struct {
Timeout string `json:"timeout"`
}
if len(m) > 0 {
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("failed to parse input: %w", err)
+ return llm.ErrorfToolOut("failed to parse input: %w", err)
}
}
if input.Timeout == "" {
@@ -65,7 +65,7 @@
// Parse timeout duration
timeout, err := time.ParseDuration(input.Timeout)
if err != nil {
- return nil, fmt.Errorf("invalid timeout duration %q: %w", input.Timeout, err)
+ return llm.ErrorfToolOut("invalid timeout duration %q: %w", input.Timeout, err)
}
// Create timeout context
@@ -76,22 +76,22 @@
// webui/src/web-components/sketch-tool-card.ts (SketchToolCardCodeReview.getStatusIcon)
if err := r.RequireNormalGitState(timeoutCtx); err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for normal git state", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
if err := r.RequireNoUncommittedChanges(timeoutCtx); err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check for uncommitted changes", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Check that the current commit is not the initial commit
currentCommit, err := r.CurrentCommit(timeoutCtx)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get current commit", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
if r.IsInitialCommit(currentCommit) {
slog.DebugContext(ctx, "CodeReviewer.Run: current commit is initial commit, nothing to review")
- return nil, fmt.Errorf("no new commits have been added, nothing to review")
+ return llm.ErrorToolOut(fmt.Errorf("no new commits have been added, nothing to review"))
}
// No matter what failures happen from here out, we will declare this to have been reviewed.
@@ -101,7 +101,7 @@
changedFiles, err := r.changedFiles(timeoutCtx, r.sketchBaseRef, currentCommit)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get changed files", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
// Prepare to analyze before/after for the impacted files.
@@ -113,7 +113,7 @@
if err != nil {
// TODO: log and skip to stuff that doesn't require packages
slog.DebugContext(ctx, "CodeReviewer.Run: failed to get packages for files", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
allPkgList := slices.Collect(maps.Keys(allPkgs))
@@ -152,7 +152,7 @@
testMsg, err := r.checkTests(timeoutCtx, allPkgList)
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check tests", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
if testMsg != "" {
errorMessages = append(errorMessages, testMsg)
@@ -161,7 +161,7 @@
goplsMsg, err := r.checkGopls(timeoutCtx, changedFiles) // includes vet checks
if err != nil {
slog.DebugContext(ctx, "CodeReviewer.Run: failed to check gopls", "err", err)
- return nil, err
+ return llm.ErrorToolOut(err)
}
if goplsMsg != "" {
errorMessages = append(errorMessages, goplsMsg)
@@ -183,7 +183,7 @@
if buf.Len() == 0 {
buf.WriteString("OK")
}
- return llm.TextContent(buf.String()), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(buf.String())}
}
func (r *CodeReviewer) initializeInitialCommitWorktree(ctx context.Context) error {
diff --git a/claudetool/keyword.go b/claudetool/keyword.go
index 7f2c492..69b789e 100644
--- a/claudetool/keyword.go
+++ b/claudetool/keyword.go
@@ -83,10 +83,10 @@
return strings.TrimSpace(string(out)), nil
}
-func keywordRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func keywordRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input keywordInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
wd := WorkingDir(ctx)
root, err := FindRepoRoot(wd)
@@ -100,7 +100,7 @@
for _, term := range input.SearchTerms {
out, err := ripgrep(ctx, wd, []string{term})
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
if len(out) > 64*1024 {
slog.InfoContext(ctx, "keyword search result too large", "term", term, "bytes", len(out))
@@ -110,7 +110,7 @@
}
if len(keep) == 0 {
- return llm.TextContent("each of those search terms yielded too many results"), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("each of those search terms yielded too many results")}
}
// peel off keywords until we get a result that fits in the query window
@@ -119,7 +119,7 @@
var err error
out, err = ripgrep(ctx, wd, keep)
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
if len(out) < 128*1024 {
break
@@ -143,10 +143,10 @@
resp, err := convo.SendMessage(initialMessage)
if err != nil {
- return nil, fmt.Errorf("failed to send relevance filtering message: %w", err)
+ return llm.ErrorfToolOut("failed to send relevance filtering message: %w", err)
}
if len(resp.Content) != 1 {
- return nil, fmt.Errorf("unexpected number of messages in relevance filtering response: %d", len(resp.Content))
+ return llm.ErrorfToolOut("unexpected number of messages in relevance filtering response: %d", len(resp.Content))
}
filtered := resp.Content[0].Text
@@ -159,7 +159,7 @@
"filtered", filtered,
)
- return llm.TextContent(resp.Content[0].Text), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(resp.Content[0].Text)}
}
func ripgrep(ctx context.Context, wd string, terms []string) (string, error) {
diff --git a/claudetool/patch.go b/claudetool/patch.go
index 252455a..815cf88 100644
--- a/claudetool/patch.go
+++ b/claudetool/patch.go
@@ -20,9 +20,9 @@
// PatchCallback defines the signature for patch tool callbacks.
// It runs after the patch tool has executed.
-// It receives the patch input, the tool call results (both content and error),
-// and returns a new, possibly altered tool call result.
-type PatchCallback func(input PatchInput, result []llm.Content, err error) ([]llm.Content, error)
+// It receives the patch input and the tool output,
+// and returns a new, possibly altered tool output.
+type PatchCallback func(input PatchInput, output llm.ToolOut) llm.ToolOut
// Patch creates a patch tool. The callback may be nil.
func Patch(callback PatchCallback) *llm.Tool {
@@ -30,13 +30,13 @@
Name: PatchName,
Description: strings.TrimSpace(PatchDescription),
InputSchema: llm.MustSchema(PatchInputSchema),
- Run: func(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+ Run: func(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input PatchInput
- result, err := patchRun(ctx, m, &input)
+ output := patchRun(ctx, m, &input)
if callback != nil {
- return callback(input, result, err)
+ return callback(input, output)
}
- return result, err
+ return output
},
}
}
@@ -112,17 +112,17 @@
// patchRun implements the guts of the patch tool.
// It populates input from m.
-func patchRun(ctx context.Context, m json.RawMessage, input *PatchInput) ([]llm.Content, error) {
+func patchRun(ctx context.Context, m json.RawMessage, input *PatchInput) llm.ToolOut {
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("failed to unmarshal user_patch input: %w", err)
+ return llm.ErrorfToolOut("failed to unmarshal user_patch input: %w", err)
}
// Validate the input
if !filepath.IsAbs(input.Path) {
- return nil, fmt.Errorf("path %q is not absolute", input.Path)
+ return llm.ErrorfToolOut("path %q is not absolute", input.Path)
}
if len(input.Patches) == 0 {
- return nil, fmt.Errorf("no patches provided")
+ return llm.ErrorToolOut(fmt.Errorf("no patches provided"))
}
// TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
@@ -135,11 +135,11 @@
switch patch.Operation {
case "prepend_bof", "append_eof", "overwrite":
default:
- return nil, fmt.Errorf("file %q does not exist", input.Path)
+ return llm.ErrorfToolOut("file %q does not exist", input.Path)
}
}
case err != nil:
- return nil, fmt.Errorf("failed to read file %q: %w", input.Path, err)
+ return llm.ErrorfToolOut("failed to read file %q: %w", input.Path, err)
}
likelyGoFile := strings.HasSuffix(input.Path, ".go")
@@ -168,7 +168,7 @@
buf.Replace(0, len(orig), patch.NewText)
case "replace":
if patch.OldText == "" {
- return nil, fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
+ return llm.ErrorfToolOut("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
}
// Attempt to apply the patch.
@@ -231,23 +231,23 @@
patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
continue
default:
- return nil, fmt.Errorf("unrecognized operation %q", patch.Operation)
+ return llm.ErrorfToolOut("unrecognized operation %q", patch.Operation)
}
}
if patchErr != nil {
- return nil, patchErr
+ return llm.ErrorToolOut(patchErr)
}
patched, err := buf.Bytes()
if err != nil {
- return nil, err
+ return llm.ErrorToolOut(err)
}
if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
- return nil, fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
+ return llm.ErrorfToolOut("failed to create directory %q: %w", filepath.Dir(input.Path), err)
}
if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
- return nil, fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
+ return llm.ErrorfToolOut("failed to write patched contents to file %q: %w", input.Path, err)
}
response := new(strings.Builder)
@@ -256,7 +256,7 @@
if parsed {
parseErr := parseGo(patched)
if parseErr != nil {
- return nil, fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
+ return llm.ErrorfToolOut("after applying all patches, the file no longer parses:\n%w", parseErr)
}
}
@@ -265,7 +265,7 @@
}
// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
- return llm.TextContent(response.String()), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(response.String())}
}
func parseGo(buf []byte) error {
diff --git a/claudetool/think.go b/claudetool/think.go
index 9611150..4fe3513 100644
--- a/claudetool/think.go
+++ b/claudetool/think.go
@@ -34,6 +34,6 @@
`
)
-func thinkRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
- return llm.TextContent("recorded"), nil
+func thinkRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ return llm.ToolOut{LLMContent: llm.TextContent("recorded")}
}
diff --git a/claudetool/todo.go b/claudetool/todo.go
index 64c7550..5c23457 100644
--- a/claudetool/todo.go
+++ b/claudetool/todo.go
@@ -109,19 +109,19 @@
return TodoFilePath(SessionID(ctx))
}
-func todoReadRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func todoReadRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
todoPath := todoFilePathForContext(ctx)
content, err := os.ReadFile(todoPath)
if os.IsNotExist(err) {
- return llm.TextContent("No todo list found. Use todo_write to create one."), nil
+ return llm.ToolOut{LLMContent: llm.TextContent("No todo list found. Use todo_write to create one.")}
}
if err != nil {
- return nil, fmt.Errorf("failed to read todo file: %w", err)
+ return llm.ErrorfToolOut("failed to read todo file: %w", err)
}
var todoList TodoList
if err := json.Unmarshal(content, &todoList); err != nil {
- return nil, fmt.Errorf("failed to parse todo file: %w", err)
+ return llm.ErrorfToolOut("failed to parse todo file: %w", err)
}
result := fmt.Sprintf(`<todo_list count="%d">%s`, len(todoList.Items), "\n")
@@ -130,13 +130,13 @@
}
result += "</todo_list>"
- return llm.TextContent(result), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(result)}
}
-func todoWriteRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
+func todoWriteRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input TodoWriteInput
if err := json.Unmarshal(m, &input); err != nil {
- return nil, fmt.Errorf("invalid input: %w", err)
+ return llm.ErrorfToolOut("invalid input: %w", err)
}
// Validate that only one task is in-progress
@@ -148,7 +148,7 @@
}
switch {
case inProgressCount > 1:
- return nil, fmt.Errorf("only one task can be 'in-progress' at a time, found %d", inProgressCount)
+ return llm.ErrorfToolOut("only one task can be 'in-progress' at a time, found %d", inProgressCount)
}
todoList := TodoList{
@@ -158,19 +158,19 @@
todoPath := todoFilePathForContext(ctx)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(todoPath), 0o700); err != nil {
- return nil, fmt.Errorf("failed to create todo directory: %w", err)
+ return llm.ErrorfToolOut("failed to create todo directory: %w", err)
}
content, err := json.Marshal(todoList)
if err != nil {
- return nil, fmt.Errorf("failed to marshal todo list: %w", err)
+ return llm.ErrorfToolOut("failed to marshal todo list: %w", err)
}
if err := os.WriteFile(todoPath, content, 0o600); err != nil {
- return nil, fmt.Errorf("failed to write todo file: %w", err)
+ return llm.ErrorfToolOut("failed to write todo file: %w", err)
}
result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks))
- return llm.TextContent(result), nil
+ return llm.ToolOut{LLMContent: llm.TextContent(result)}
}
diff --git a/claudetool/todo_test.go b/claudetool/todo_test.go
index ac36cc2..6f511e8 100644
--- a/claudetool/todo_test.go
+++ b/claudetool/todo_test.go
@@ -16,10 +16,11 @@
todoPath := todoFilePathForContext(ctx)
os.Remove(todoPath)
- result, err := todoReadRun(ctx, []byte("{}"))
- if err != nil {
- t.Fatalf("expected no error, got %v", err)
+ toolOut := todoReadRun(ctx, []byte("{}"))
+ if toolOut.Error != nil {
+ t.Fatalf("expected no error, got %v", toolOut.Error)
}
+ result := toolOut.LLMContent
if len(result) != 1 {
t.Fatalf("expected 1 content item, got %d", len(result))
@@ -49,10 +50,11 @@
writeInput := TodoWriteInput{Tasks: todos}
writeInputJSON, _ := json.Marshal(writeInput)
- result, err := todoWriteRun(ctx, writeInputJSON)
- if err != nil {
- t.Fatalf("expected no error, got %v", err)
+ toolOut := todoWriteRun(ctx, writeInputJSON)
+ if toolOut.Error != nil {
+ t.Fatalf("expected no error, got %v", toolOut.Error)
}
+ result := toolOut.LLMContent
if len(result) != 1 {
t.Fatalf("expected 1 content item, got %d", len(result))
@@ -64,10 +66,11 @@
}
// Read the todos back
- result, err = todoReadRun(ctx, []byte("{}"))
- if err != nil {
- t.Fatalf("expected no error, got %v", err)
+ toolOut = todoReadRun(ctx, []byte("{}"))
+ if toolOut.Error != nil {
+ t.Fatalf("expected no error, got %v", toolOut.Error)
}
+ result = toolOut.LLMContent
if len(result) != 1 {
t.Fatalf("expected 1 content item, got %d", len(result))
@@ -107,14 +110,14 @@
writeInput := TodoWriteInput{Tasks: todos}
writeInputJSON, _ := json.Marshal(writeInput)
- _, err := todoWriteRun(ctx, writeInputJSON)
- if err == nil {
+ toolOut := todoWriteRun(ctx, writeInputJSON)
+ if toolOut.Error == nil {
t.Fatal("expected error for multiple in_progress tasks, got none")
}
expected := "only one task can be 'in-progress' at a time, found 2"
- if err.Error() != expected {
- t.Errorf("expected error %q, got %q", expected, err.Error())
+ if toolOut.Error.Error() != expected {
+ t.Errorf("expected error %q, got %q", expected, toolOut.Error.Error())
}
}