diff --git a/pkg/tools/web.go b/pkg/tools/web.go index b7abbe44b..8aca1fdb1 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -630,6 +630,11 @@ func (t *WebFetchTool) Parameters() map[string]any { "description": "Maximum characters to extract", "minimum": 100.0, }, + "llmMaxChars": map[string]any{ + "type": "integer", + "description": "Maximum characters included in LLM-facing content (defaults to maxChars)", + "minimum": 100.0, + }, }, "required": []string{"url"}, } @@ -655,9 +660,15 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } maxChars := t.maxChars - if mc, ok := args["maxChars"].(float64); ok { - if int(mc) > 100 { - maxChars = int(mc) + if raw, ok := args["maxChars"]; ok { + if mc, err := toInt(raw); err == nil && mc > 100 { + maxChars = mc + } + } + llmMaxChars := maxChars + if raw, ok := args["llmMaxChars"]; ok { + if mc, err := toInt(raw); err == nil && mc > 100 { + llmMaxChars = mc } } @@ -719,6 +730,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if truncated { text = text[:maxChars] } + llmText := text + llmTruncated := false + if len(llmText) > llmMaxChars { + llmText = llmText[:llmMaxChars] + llmTruncated = true + } result := map[string]any{ "url": urlStr, @@ -730,15 +747,18 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } resultJSON, _ := json.MarshalIndent(result, "", " ") + llmPayload := map[string]any{ + "url": urlStr, + "status": resp.StatusCode, + "extractor": extractor, + "sourceLength": len(text), + "truncated": truncated || llmTruncated, + "text": llmText, + } + llmJSON, _ := json.MarshalIndent(llmPayload, "", " ") return &ToolResult{ - ForLLM: fmt.Sprintf( - "Fetched %d bytes from %s (extractor: %s, truncated: %v)", - len(text), - urlStr, - extractor, - truncated, - ), + ForLLM: string(llmJSON), ForUser: string(resultJSON), } } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 602131e0a..81f560e30 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -37,9 +37,9 @@ func TestWebTool_WebFetch_Success(t *testing.T) { t.Errorf("Expected ForUser to contain 'Test Page', got: %s", result.ForUser) } - // ForLLM should contain summary - if !strings.Contains(result.ForLLM, "bytes") && !strings.Contains(result.ForLLM, "extractor") { - t.Errorf("Expected ForLLM to contain summary, got: %s", result.ForLLM) + // ForLLM should contain extracted content for model-side reasoning + if !strings.Contains(result.ForLLM, "Test Page") { + t.Errorf("Expected ForLLM to contain fetched content, got: %s", result.ForLLM) } } @@ -174,6 +174,43 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { } } +func TestWebTool_WebFetch_LLMTruncation(t *testing.T) { + longContent := strings.Repeat("abc", 2000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(longContent)) + })) + defer server.Close() + + tool := NewWebFetchTool(12000) + ctx := context.Background() + args := map[string]any{ + "url": server.URL, + "maxChars": 6000, + "llmMaxChars": 500, + } + + result := tool.Execute(ctx, args) + if result.IsError { + t.Fatalf("Expected success, got IsError=true: %s", result.ForLLM) + } + + var llmResult map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &llmResult); err != nil { + t.Fatalf("failed to decode ForLLM JSON: %v", err) + } + + text, ok := llmResult["text"].(string) + if !ok { + t.Fatalf("expected text field in ForLLM payload, got: %v", llmResult) + } + if len(text) > 600 { + t.Fatalf("expected LLM text to be truncated to ~500 chars, got len=%d", len(text)) + } +} + // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})