From 7744e44704ed46fb267d78dd7d1f1133cd37217f Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Wed, 6 May 2026 17:55:31 -0700 Subject: [PATCH] feat(agents): label subagent tool feedback --- pkg/agent/agent_utils.go | 38 +++++++++++++++++++++ pkg/agent/pipeline_execute.go | 18 ++++++++++ pkg/channels/tool_feedback_animator.go | 30 +++++++++++++--- pkg/channels/tool_feedback_animator_test.go | 17 +++++++++ pkg/utils/tool_feedback.go | 25 ++++++++++++-- pkg/utils/tool_feedback_test.go | 14 ++++++++ 6 files changed, 134 insertions(+), 8 deletions(-) diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 9228b6d55..4fad83c0a 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -181,6 +181,44 @@ func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() } +func toolFeedbackTitleForTurn(ts *turnState) string { + if ts == nil || !strings.HasPrefix(strings.TrimSpace(ts.sessionKey), "subturn-") { + return "" + } + if ts.agent == nil { + return "Subagent" + } + if title := displayAgentName(ts.agent.ID); title != "" { + return title + } + return "Subagent" +} + +func displayAgentName(agentID string) string { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return "" + } + parts := strings.FieldsFunc(agentID, func(r rune) bool { + return r == '-' || r == '_' || r == ' ' + }) + var b strings.Builder + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(strings.ToUpper(part[:1])) + if len(part) > 1 { + b.WriteString(part[1:]) + } + } + return b.String() +} + func cloneEventArguments(args map[string]any) map[string]any { if len(args) == 0 { return nil diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index f8edb8954..b1550720a 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -94,6 +94,15 @@ toolLoop: toolFeedbackExplanation, toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) + if title := toolFeedbackTitleForTurn(ts); title != "" { + feedbackMsg = utils.FormatToolFeedbackMessageWithStyleAndTitle( + al.cfg.Agents.Defaults.GetToolFeedbackStyle(), + title, + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + } fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() @@ -376,6 +385,15 @@ toolLoop: toolFeedbackExplanation, toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), ) + if title := toolFeedbackTitleForTurn(ts); title != "" { + feedbackMsg = utils.FormatToolFeedbackMessageWithStyleAndTitle( + al.cfg.Agents.Defaults.GetToolFeedbackStyle(), + title, + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + } fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go index 1703a622c..b9d772203 100644 --- a/pkg/channels/tool_feedback_animator.go +++ b/pkg/channels/tool_feedback_animator.go @@ -247,7 +247,7 @@ func mergeToolFeedbackContent(previous, next string) string { seen := make(map[string]struct{}) addLine := func(line string) { line = strings.TrimSpace(line) - if line == "" || strings.EqualFold(line, "Working...") { + if line == "" || isWorkingSummaryHeader(line) { return } if _, ok := seen[line]; ok { @@ -266,15 +266,35 @@ func mergeToolFeedbackContent(previous, next string) string { if len(lines) > maxMergedToolFeedbackLines { lines = lines[len(lines)-maxMergedToolFeedbackLines:] } - if len(lines) == 0 { - return "Working..." + header := workingSummaryHeader(previous) + if candidate := workingSummaryHeader(next); candidate != "" { + header = candidate } - return "Working...\n" + strings.Join(lines, "\n") + if header == "" { + header = "Working..." + } + if len(lines) == 0 { + return header + } + return header + "\n" + strings.Join(lines, "\n") } func isWorkingSummaryToolFeedback(content string) bool { + return isWorkingSummaryHeader(workingSummaryHeader(content)) +} + +func workingSummaryHeader(content string) string { firstLine, _, _ := strings.Cut(strings.TrimSpace(content), "\n") - return strings.EqualFold(strings.TrimSpace(firstLine), "Working...") + firstLine = strings.TrimSpace(firstLine) + if isWorkingSummaryHeader(firstLine) { + return firstLine + } + return "" +} + +func isWorkingSummaryHeader(line string) bool { + line = strings.TrimSpace(line) + return strings.EqualFold(line, "Working...") || strings.HasSuffix(strings.ToLower(line), " working...") } func toolFeedbackAnimationIntervalFor(content string) time.Duration { diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go index bade38123..52adc6f13 100644 --- a/pkg/channels/tool_feedback_animator_test.go +++ b/pkg/channels/tool_feedback_animator_test.go @@ -165,3 +165,20 @@ func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) } } + +func TestMergeToolFeedbackContent_PreservesNamedWorkingSummaryHeader(t *testing.T) { + got := mergeToolFeedbackContent( + "Deep Research working...\n• tool: `read_file`", + "Deep Research working...\n• tool: `web_fetch`", + ) + want := "Deep Research working...\n• tool: `read_file`\n• tool: `web_fetch`" + if got != want { + t.Fatalf("mergeToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestIsWorkingSummaryToolFeedback_AcceptsNamedHeader(t *testing.T) { + if !isWorkingSummaryToolFeedback("Deep Research working...\n• tool: `read_file`") { + t.Fatal("expected named working summary to be recognized") + } +} diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index 2b84058f3..c2b5f2b2c 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -66,22 +66,41 @@ func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string // progress UI should not leak internal paths, large JSON blobs, secrets, or // model-drafted reasoning-like text. func FormatToolFeedbackMessageWithStyle(style, toolName, explanation, argsPreview string) string { + return FormatToolFeedbackMessageWithStyleAndTitle(style, "", toolName, explanation, argsPreview) +} + +func FormatToolFeedbackMessageWithStyleAndTitle( + style, title, toolName, explanation, argsPreview string, +) string { if strings.EqualFold(strings.TrimSpace(style), ToolFeedbackStyleWorkingSummary) { - return FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview) + return FormatWorkingSummaryToolFeedbackMessageWithTitle(title, toolName, argsPreview) } return FormatToolFeedbackMessage(toolName, explanation, argsPreview) } func FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview string) string { + return FormatWorkingSummaryToolFeedbackMessageWithTitle("", toolName, argsPreview) +} + +func FormatWorkingSummaryToolFeedbackMessageWithTitle(title, toolName, argsPreview string) string { toolName = strings.TrimSpace(toolName) + header := workingSummaryHeader(title) if toolName == "" { - return "Working..." + return header } line := fmt.Sprintf("• tool: `%s`", sanitizeToolFeedbackCodeSpan(toolName)) if summary := summarizeToolFeedbackArgs(toolName, argsPreview); summary != "" { line += fmt.Sprintf(" — `%s`", sanitizeToolFeedbackCodeSpan(summary)) } - return "Working...\n" + line + return header + "\n" + line +} + +func workingSummaryHeader(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "Working..." + } + return title + " working..." } func sanitizeToolFeedbackCodeSpan(text string) string { diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index a0c51ff7d..96b36c148 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -54,6 +54,20 @@ func TestFormatToolFeedbackMessageWithStyle_WorkingSummary(t *testing.T) { } } +func TestFormatToolFeedbackMessageWithStyleAndTitle_WorkingSummary(t *testing.T) { + got := FormatToolFeedbackMessageWithStyleAndTitle( + "working_summary", + "DeepResearch", + "read_file", + "", + "{\"path\":\"README.md\"}", + ) + want := "DeepResearch working...\n• tool: `read_file` — `README.md`" + if got != want { + t.Fatalf("FormatToolFeedbackMessageWithStyleAndTitle() = %q, want %q", got, want) + } +} + func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsFileBasenameOnly(t *testing.T) { got := FormatToolFeedbackMessageWithStyle( "working_summary",