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.",