diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index b0aa3b468..030b65a6d 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -1965,6 +1965,17 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi } } +func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) { + got := toolFeedbackArgsPreview(map[string]any{ + "path": "README.md", + "limit": 42, + }, 128) + want := "{\n \"limit\": 42,\n \"path\": \"README.md\"\n}" + if got != want { + t.Fatalf("toolFeedbackArgsPreview() = %q, want %q", got, want) + } +} + type picoInterleavedContentProvider struct { calls int } @@ -3940,6 +3951,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { if !strings.Contains(outbound.Content, "check tool feedback") { t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, heartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } if strings.Contains(outbound.Content, "Previous turn explanation") { t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) } @@ -4012,6 +4029,12 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) if !strings.Contains(outbound.Content, "check reasoning fallback") { t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, heartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } if strings.Contains(outbound.Content, "Read README.md first") { t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) } @@ -4310,7 +4333,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi } } - if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { + if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text\n```json\n{\n \"value\": \"x\"\n}\n```" { t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) } if outputs[1] != "final model text" { diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index ff98dad68..17fb9dd1f 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -4,6 +4,7 @@ package agent import ( "context" + "encoding/json" "fmt" "path/filepath" "strings" @@ -170,6 +171,18 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string { return "" } +func toolFeedbackArgsPreview(args map[string]any, maxLen int) string { + if args == nil { + args = map[string]any{} + } + + argsJSON, err := json.MarshalIndent(args, "", " ") + if err != nil { + return utils.Truncate(fmt.Sprintf("%v", args), maxLen) + } + return utils.Truncate(string(argsJSON), maxLen) +} + func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { return false diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 48e72e096..727e3428f 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -81,13 +81,18 @@ toolLoop: ) if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() toolFeedbackExplanation := toolFeedbackExplanationForToolCall( exec.response, tc, messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + toolFeedbackMaxLen, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() @@ -358,13 +363,18 @@ toolLoop: ) if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() toolFeedbackExplanation := toolFeedbackExplanationForToolCall( exec.response, tc, messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + toolFeedbackMaxLen, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go index a05b39877..a54a1c2c7 100644 --- a/pkg/channels/telegram/parser_markdown_to_html_test.go +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -65,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "a & b < c > d", expected: "a & b < c > d", }, + { + name: "code block with language", + input: "```json\n{\n \"path\": \"README.md\"\n}\n```", + expected: "
{\n  \"path\": \"README.md\"\n}\n
", + }, } for _, tc := range cases { diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index 1a8b6c747..de7cb467e 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -7,21 +7,31 @@ import ( const ToolFeedbackContinuationHint = "Continuing the current task." -// FormatToolFeedbackMessage renders the model-provided explanation for why a -// tool is being executed. When the model does not provide one, it keeps only -// the tool line and does not expose raw arguments or fallback text. -func FormatToolFeedbackMessage(toolName, explanation string) string { +// FormatToolFeedbackMessage renders a tool feedback message for chat channels. +// It keeps the tool name on the first line for animation and can include both +// a human explanation and the serialized tool arguments in the body. +func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string { toolName = strings.TrimSpace(toolName) explanation = strings.TrimSpace(explanation) + argsPreview = strings.TrimSpace(argsPreview) + + bodyLines := make([]string, 0, 2) + if explanation != "" { + bodyLines = append(bodyLines, explanation) + } + if argsPreview != "" { + bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```") + } + body := strings.Join(bodyLines, "\n") if toolName == "" { - return explanation + return body } - if explanation == "" { + if body == "" { return fmt.Sprintf("\U0001f527 `%s`", toolName) } - return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) + return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body) } // FitToolFeedbackMessage keeps tool feedback within a single outbound message. diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index 316ce2408..c30f53827 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -6,29 +6,38 @@ func TestFormatToolFeedbackMessage(t *testing.T) { got := FormatToolFeedbackMessage( "read_file", "I will read README.md first to confirm the current project structure.", + "{\n \"path\": \"README.md\"\n}", ) - want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure." + want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```" if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } -func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) { - got := FormatToolFeedbackMessage("read_file", "") - want := "\U0001f527 `read_file`" +func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}") + want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```" if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { - got := FormatToolFeedbackMessage("", "Continue drafting the final response.") + got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "") want := "Continue drafting the final response." if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } +func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "") + want := "\U0001f527 `read_file`" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { got := FitToolFeedbackMessage( "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",