diff --git a/internal/llm/tool.go b/internal/llm/tool.go index b5bd8f5..5c90b06 100644 --- a/internal/llm/tool.go +++ b/internal/llm/tool.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "os/exec" "strings" @@ -175,33 +176,50 @@ func (r *Registry) ToAPI() []APITool { return tools } +// binaryOutputPrefix is the message shown when tool output is detected as binary. +const binaryOutputPrefix = "error: binary output detected: " + // ToolResult holds the structured output from a tool execution. type ToolResult struct { - Stdout string - Stderr string + Stdout []byte + Stderr []byte 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. +// +// http.DetectContentType implements the WHATWG MIME-sniffing spec and +// returns text/plain for any content consisting entirely of printable +// characters and whitespace. Text-based formats like JSON, JavaScript, +// and XML will be detected as text/plain; charset=utf-8. We assume +// checking for the "text/" prefix is sufficient. 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") + if len(r.Stdout) > 0 { + ctype := http.DetectContentType(r.Stdout) + if !strings.HasPrefix(ctype, "text/") { + fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype) + } else { + buf.Write(r.Stdout) + if r.Stdout[len(r.Stdout)-1] != '\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") + if len(r.Stderr) > 0 { + ctype := http.DetectContentType(r.Stderr) + if !strings.HasPrefix(ctype, "text/") { + fmt.Fprintf(&buf, binaryOutputPrefix+"%s\n", ctype) + } else { + buf.Write(r.Stderr) + if r.Stderr[len(r.Stderr)-1] != '\n' { + fmt.Fprint(&buf, "\n") + } } } @@ -301,8 +319,8 @@ func (r *Registry) Execute( // ProcessState is set; return it along with captured output. default: return &ToolResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), ProcessState: cmd.ProcessState, }, nil } @@ -310,8 +328,8 @@ func (r *Registry) Execute( // No error. Process exited with status 0. return &ToolResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), ProcessState: cmd.ProcessState, }, nil } diff --git a/internal/llm/tool_test.go b/internal/llm/tool_test.go index a5d6a72..a998fe1 100644 --- a/internal/llm/tool_test.go +++ b/internal/llm/tool_test.go @@ -1,6 +1,7 @@ package llm import ( + "bytes" "context" "os/exec" "strings" @@ -221,24 +222,54 @@ func TestToolResult_FormatResult(t *testing.T) { }{ { name: "success with stdout", - result: ToolResult{Stdout: "hello\n", Stderr: "", ProcessState: cmdOK.ProcessState}, + result: ToolResult{Stdout: []byte("hello\n"), Stderr: nil, ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "hello", "▸ stderr:", "┗ exit: 0"}, }, { name: "failure with stderr", - result: ToolResult{Stdout: "", Stderr: "error occurred\n", ProcessState: cmdFail.ProcessState}, + result: ToolResult{Stdout: nil, Stderr: []byte("error occurred\n"), ProcessState: cmdFail.ProcessState}, wantContains: []string{"▸ stdout:", "▸ stderr:", "error occurred", "┗ exit: 3"}, }, { name: "both stdout and stderr", - result: ToolResult{Stdout: "output", Stderr: "warning", ProcessState: cmdOK.ProcessState}, + result: ToolResult{Stdout: []byte("output"), Stderr: []byte("warning"), ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "output", "▸ stderr:", "warning", "┗ exit: 0"}, }, { name: "empty output", - result: ToolResult{Stdout: "", Stderr: "", ProcessState: cmdOK.ProcessState}, + result: ToolResult{Stdout: nil, Stderr: nil, ProcessState: cmdOK.ProcessState}, wantContains: []string{"▸ stdout:", "▸ stderr:", "┗ exit: 0"}, }, + { + name: "binary stdout (PNG)", + result: ToolResult{Stdout: []byte("\x89PNG\r\n\x1a\n"), Stderr: nil, ProcessState: cmdOK.ProcessState}, + wantContains: []string{"▸ stdout:", binaryOutputPrefix, "image/png", "▸ stderr:", "┗ exit: 0"}, + }, + { + name: "binary stderr", + result: ToolResult{Stdout: nil, Stderr: []byte("\x89PNG\r\n\x1a\n"), ProcessState: cmdOK.ProcessState}, + wantContains: []string{"▸ stdout:", "▸ stderr:", binaryOutputPrefix, "image/png", "┗ exit: 0"}, + }, + { + name: "HTML", + result: ToolResult{Stdout: []byte("Hello\n"), Stderr: nil, ProcessState: cmdOK.ProcessState}, + wantContains: []string{"▸ stdout:", "", "▸ stderr:", "┗ exit: 0"}, + }, + { + name: "XML", + result: ToolResult{Stdout: []byte("\n"), Stderr: nil, ProcessState: cmdOK.ProcessState}, + wantContains: []string{"▸ stdout:", "