feat(agents): add working summary tool feedback

This commit is contained in:
Anton Bogdanovich 2026-05-05 17:13:39 -07:00
parent 788cda5c7a
commit 194781c3e9
12 changed files with 443 additions and 46 deletions

View file

@ -16,7 +16,8 @@
"tool_feedback": { "tool_feedback": {
"enabled": false, "enabled": false,
"max_args_length": 300, "max_args_length": 300,
"separate_messages": false "separate_messages": false,
"style": "raw"
} }
} }
}, },

View file

@ -66,7 +66,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific
"tool_feedback": { "tool_feedback": {
"enabled": true, "enabled": true,
"max_args_length": 300, "max_args_length": 300,
"separate_messages": true "separate_messages": true,
"style": "raw"
} }
} }
} }
@ -80,6 +81,39 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
{"query": "picoclaw release notes"} {"query": "picoclaw release notes"}
``` ```
Set `style` to `working_summary` to use a compact, non-argument progress message that can be edited in place as tools run:
```json
{
"agents": {
"defaults": {
"tool_feedback": {
"enabled": true,
"separate_messages": false,
"style": "working_summary"
}
}
}
}
```
The message starts as:
```text
Working...
• tool: `read_file``README.md`
```
When more tools run, editable channels merge recent tool lines into the same progress message:
```text
Working...
• tool: `read_file``README.md`
• tool: `exec``test.sh`
• tool: `write_file``config.json`
```
The `working_summary` style intentionally does not show raw tool arguments, explanations, URLs, command arguments, environment variables, or full paths. It only shows the tool name plus a safe basename for file tools and executable commands where available. This keeps progress visible without exposing secrets in chat history.
### Options ### Options
@ -88,14 +122,17 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
| `enabled` | bool | `false` | Send a chat notification for each tool call | | `enabled` | bool | `false` | Send a chat notification for each tool call |
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message | | `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | | `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
| `style` | string | `raw` | Feedback format. Use `raw` for the original tool/explanation/argument preview, or `working_summary` for compact progress lines without raw arguments |
### Environment variables ### Environment variables
Both fields can also be set via environment variables: These fields can also be set via environment variables:
```bash ```bash
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300 PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH=300
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES=false
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_STYLE=working_summary
``` ```
> **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag. > **Note:** `tool_feedback` is independent of `--debug` mode. It works in production and does not require the gateway to be started with any special flag.

View file

@ -3973,6 +3973,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
ToolFeedback: config.ToolFeedbackConfig{ ToolFeedback: config.ToolFeedbackConfig{
Enabled: true, Enabled: true,
MaxArgsLength: 300, MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
}, },
}, },
}, },
@ -4019,6 +4020,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
ToolFeedback: config.ToolFeedbackConfig{ ToolFeedback: config.ToolFeedbackConfig{
Enabled: true, Enabled: true,
MaxArgsLength: 300, MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
}, },
}, },
}, },
@ -4048,7 +4050,6 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
select { select {
case outbound := <-msgBus.OutboundChan(): case outbound := <-msgBus.OutboundChan():
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
if outbound.Channel != "telegram" { if outbound.Channel != "telegram" {
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
} }
@ -4058,20 +4059,13 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" { if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" {
t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) t.Fatalf("unexpected tool feedback context: %+v", outbound.Context)
} }
if !strings.Contains(outbound.Content, "`read_file`") { if !strings.Contains(outbound.Content, "tool: `read_file`") {
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
} }
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) strings.Contains(outbound.Content, "check tool feedback") ||
} strings.Contains(outbound.Content, "\"path\":") {
if !strings.Contains(outbound.Content, "check tool feedback") { t.Fatalf("tool feedback content = %q, should only include compact tool names", outbound.Content)
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, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
} }
if strings.Contains(outbound.Content, "Previous turn explanation") { if strings.Contains(outbound.Content, "Previous turn explanation") {
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
@ -4283,6 +4277,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
ToolFeedback: config.ToolFeedbackConfig{ ToolFeedback: config.ToolFeedbackConfig{
Enabled: true, Enabled: true,
MaxArgsLength: 300, MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
}, },
}, },
}, },
@ -4313,20 +4308,14 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
select { select {
case outbound := <-msgBus.OutboundChan(): case outbound := <-msgBus.OutboundChan():
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
if !strings.Contains(outbound.Content, "`read_file`") { if !strings.Contains(outbound.Content, "tool: `read_file`") {
t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content)
} }
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) strings.Contains(outbound.Content, "check reasoning fallback") ||
} strings.Contains(outbound.Content, "\"path\":") ||
if !strings.Contains(outbound.Content, "check reasoning fallback") { strings.Contains(outbound.Content, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) t.Fatalf("tool feedback content = %q, should only include compact tool names", 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, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
} }
if strings.Contains(outbound.Content, "Read README.md first") { if strings.Contains(outbound.Content, "Read README.md first") {
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)

View file

@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
) )
func newHookTestLoop( func newHookTestLoop(
@ -809,6 +810,7 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
defer cleanup() defer cleanup()
al.cfg.Agents.Defaults.ToolFeedback.Enabled = true al.cfg.Agents.Defaults.ToolFeedback.Enabled = true
al.cfg.Agents.Defaults.ToolFeedback.Style = utils.ToolFeedbackStyleWorkingSummary
al.RegisterTool(&echoTextTool{}) al.RegisterTool(&echoTextTool{})
al.RegisterTool(&echoTextRewrittenTool{}) al.RegisterTool(&echoTextRewrittenTool{})
if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil { if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil {
@ -835,10 +837,10 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
select { select {
case outbound := <-msgBus.OutboundChan(): case outbound := <-msgBus.OutboundChan():
if !strings.Contains(outbound.Content, "`echo_text_rewritten`") { if !strings.Contains(outbound.Content, "tool: `echo_text_rewritten`") {
t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content) t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content)
} }
if strings.Contains(outbound.Content, "`echo_text`") { if strings.Contains(outbound.Content, "tool: `echo_text`\n") {
t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content) t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content)
} }
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):

View file

@ -88,7 +88,8 @@ toolLoop:
tc, tc,
messages, messages,
) )
feedbackMsg := utils.FormatToolFeedbackMessage( feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
toolName, toolName,
toolFeedbackExplanation, toolFeedbackExplanation,
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
@ -369,7 +370,8 @@ toolLoop:
tc, tc,
messages, messages,
) )
feedbackMsg := utils.FormatToolFeedbackMessage( feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
toolName, toolName,
toolFeedbackExplanation, toolFeedbackExplanation,
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),

View file

@ -441,6 +441,20 @@ func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) {
} }
} }
func TestParseContent_WorkingSummaryToolNamesStayCode(t *testing.T) {
content := "Working...\n• tool: `inventorydb__get_location`"
htmlContent := parseContent(content, false)
if !strings.Contains(htmlContent, "<code>inventorydb__get_location</code>") {
t.Fatalf("parseContent() HTML = %q, want tool name code span", htmlContent)
}
markdownV2Content := parseContent(content, true)
if !strings.Contains(markdownV2Content, "`inventorydb__get_location`") {
t.Fatalf("parseContent() MarkdownV2 = %q, want tool name code span", markdownV2Content)
}
}
func TestSend_LongMessage_SingleCall(t *testing.T) { func TestSend_LongMessage_SingleCall(t *testing.T) {
// With WithMaxMessageLength(4000), the Manager pre-splits messages before // With WithMaxMessageLength(4000), the Manager pre-splits messages before
// they reach Send(). A message at exactly 4000 chars should go through // they reach Send(). A message at exactly 4000 chars should go through

View file

@ -8,6 +8,8 @@ import (
) )
const toolFeedbackAnimationInterval = 3 * time.Second const toolFeedbackAnimationInterval = 3 * time.Second
const workingSummaryToolFeedbackAnimationInterval = 10 * time.Second
const maxMergedToolFeedbackLines = 8
const initialToolFeedbackAnimationFrame = "" const initialToolFeedbackAnimationFrame = ""
@ -122,13 +124,17 @@ func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content strin
return "", false, nil return "", false, nil
} }
animatedContent := InitialAnimatedToolFeedbackContent(content) mergedContent := content
if isWorkingSummaryToolFeedback(baseContent) || isWorkingSummaryToolFeedback(content) {
mergedContent = mergeToolFeedbackContent(baseContent, content)
}
animatedContent := InitialAnimatedToolFeedbackContent(mergedContent)
if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
a.Record(chatID, msgID, baseContent) a.Record(chatID, msgID, baseContent)
return "", true, err return "", true, err
} }
a.Record(chatID, msgID, content) a.Record(chatID, msgID, mergedContent)
return msgID, true, nil return msgID, true, nil
} }
@ -163,7 +169,7 @@ func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
defer close(entry.done) defer close(entry.done)
ticker := time.NewTicker(toolFeedbackAnimationInterval) ticker := time.NewTicker(toolFeedbackAnimationIntervalFor(entry.baseContent))
defer ticker.Stop() defer ticker.Stop()
frameIdx := 1 frameIdx := 1
@ -227,6 +233,57 @@ func appendToolFeedbackFrame(firstLine, frame string) string {
return firstLine + frame return firstLine + frame
} }
func mergeToolFeedbackContent(previous, next string) string {
previous = strings.TrimSpace(previous)
next = strings.TrimSpace(next)
if previous == "" {
return next
}
if next == "" {
return previous
}
lines := make([]string, 0, maxMergedToolFeedbackLines+1)
seen := make(map[string]struct{})
addLine := func(line string) {
line = strings.TrimSpace(line)
if line == "" || strings.EqualFold(line, "Working...") {
return
}
if _, ok := seen[line]; ok {
return
}
seen[line] = struct{}{}
lines = append(lines, line)
}
for _, line := range strings.Split(previous, "\n") {
addLine(line)
}
for _, line := range strings.Split(next, "\n") {
addLine(line)
}
if len(lines) > maxMergedToolFeedbackLines {
lines = lines[len(lines)-maxMergedToolFeedbackLines:]
}
if len(lines) == 0 {
return "Working..."
}
return "Working...\n" + strings.Join(lines, "\n")
}
func isWorkingSummaryToolFeedback(content string) bool {
firstLine, _, _ := strings.Cut(strings.TrimSpace(content), "\n")
return strings.EqualFold(strings.TrimSpace(firstLine), "Working...")
}
func toolFeedbackAnimationIntervalFor(content string) time.Duration {
if isWorkingSummaryToolFeedback(content) {
return workingSummaryToolFeedbackAnimationInterval
}
return toolFeedbackAnimationInterval
}
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
if entry == nil { if entry == nil {
return return

View file

@ -75,16 +75,17 @@ func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
if messageID != "msg-1" { if messageID != "msg-1" {
t.Fatalf("messageID = %q, want msg-1", messageID) t.Fatalf("messageID = %q, want msg-1", messageID)
} }
if content != "🔧 `write_file`\nUpdating config" { want := "Working...\n• tool: `read_file`\n• tool: `write_file`"
if content != want {
t.Fatalf("content = %q, want updated animated content", content) t.Fatalf("content = %q, want updated animated content", content)
} }
return nil return nil
}) })
defer animator.StopAll() defer animator.StopAll()
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") animator.Record("chat-1", "msg-1", "Working...\n• tool: `read_file`")
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") msgID, handled, err := animator.Update(context.Background(), "chat-1", "Working...\n• tool: `write_file`")
if err != nil { if err != nil {
t.Fatalf("Update() error = %v", err) t.Fatalf("Update() error = %v", err)
} }
@ -96,6 +97,51 @@ func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
} }
} }
func TestToolFeedbackAnimator_UpdateRawFeedbackReplacesContent(t *testing.T) {
var animator *ToolFeedbackAnimator
animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error {
if _, ok := animator.Current(chatID); ok {
t.Fatal("expected tracked tool feedback to be stopped before edit")
}
if messageID != "msg-1" {
t.Fatalf("messageID = %q, want msg-1", messageID)
}
want := "🔧 `write_file`\nWriting config"
if content != want {
t.Fatalf("content = %q, want replacement content", content)
}
return nil
})
defer animator.StopAll()
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nReading config")
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nWriting config")
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if !handled {
t.Fatal("Update() handled = false, want true")
}
if msgID != "msg-1" {
t.Fatalf("Update() msgID = %q, want msg-1", msgID)
}
}
func TestToolFeedbackAnimationIntervalForWorkingSummary(t *testing.T) {
got := toolFeedbackAnimationIntervalFor("Working...\n• tool: `read_file`")
if got != workingSummaryToolFeedbackAnimationInterval {
t.Fatalf("toolFeedbackAnimationIntervalFor() = %v, want %v", got, workingSummaryToolFeedbackAnimationInterval)
}
}
func TestToolFeedbackAnimationIntervalForRawFeedback(t *testing.T) {
got := toolFeedbackAnimationIntervalFor("🔧 `read_file`\nReading config")
if got != toolFeedbackAnimationInterval {
t.Fatalf("toolFeedbackAnimationIntervalFor() = %v, want %v", got, toolFeedbackAnimationInterval)
}
}
func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
editErr := errors.New("edit failed") editErr := errors.New("edit failed")
animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error {
@ -103,9 +149,9 @@ func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
}) })
defer animator.StopAll() defer animator.StopAll()
animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") animator.Record("chat-1", "msg-1", "Working...\n• tool: `read_file`")
msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") msgID, handled, err := animator.Update(context.Background(), "chat-1", "Working...\n• tool: `write_file`")
if !handled { if !handled {
t.Fatal("Update() handled = false, want true") t.Fatal("Update() handled = false, want true")
} }

View file

@ -252,6 +252,7 @@ type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"` SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
Style string `json:"style,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_STYLE"`
} }
type AgentDefaults struct { type AgentDefaults struct {
@ -311,6 +312,10 @@ func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
return d.ToolFeedback.SeparateMessages return d.ToolFeedback.SeparateMessages
} }
func (d *AgentDefaults) GetToolFeedbackStyle() string {
return strings.TrimSpace(d.ToolFeedback.Style)
}
// GetModelName returns the effective model name for the agent defaults. // GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility. // It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string { func (d *AgentDefaults) GetModelName() string {

View file

@ -790,6 +790,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false") t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
} }
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "" {
t.Fatalf("DefaultConfig().Agents.Defaults.GetToolFeedbackStyle() = %q, want empty/raw default", got)
}
} }
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
@ -813,6 +816,29 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file") t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
} }
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "" {
t.Fatalf("agents.defaults.tool_feedback.style = %q, want empty/raw default when unset", got)
}
}
func TestLoadConfig_ToolFeedbackStyle(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(
configPath,
[]byte(`{"version":1,"agents":{"defaults":{"tool_feedback":{"enabled":true,"style":"working_summary"}}}}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if got := cfg.Agents.Defaults.GetToolFeedbackStyle(); got != "working_summary" {
t.Fatalf("agents.defaults.tool_feedback.style = %q, want working_summary", got)
}
} }
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {

View file

@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"regexp"
"strings" "strings"
) )
@ -30,6 +32,8 @@ func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) st
return strings.TrimSpace(buf.String()) return strings.TrimSpace(buf.String())
} }
const ToolFeedbackStyleWorkingSummary = "working_summary"
// FormatToolFeedbackMessage renders a tool feedback message for chat channels. // FormatToolFeedbackMessage renders a tool feedback message for chat channels.
// It keeps the tool name on the first line for animation and can include both // 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. // a human explanation and the serialized tool arguments in the body.
@ -57,6 +61,147 @@ func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body) return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
} }
// FormatToolFeedbackMessageWithStyle renders alternate tool feedback styles.
// The working_summary style intentionally omits explanations and raw arguments:
// 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 {
if strings.EqualFold(strings.TrimSpace(style), ToolFeedbackStyleWorkingSummary) {
return FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview)
}
return FormatToolFeedbackMessage(toolName, explanation, argsPreview)
}
func FormatWorkingSummaryToolFeedbackMessage(toolName, argsPreview string) string {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return "Working..."
}
line := fmt.Sprintf("• tool: `%s`", sanitizeToolFeedbackCodeSpan(toolName))
if summary := summarizeToolFeedbackArgs(toolName, argsPreview); summary != "" {
line += fmt.Sprintf(" — `%s`", sanitizeToolFeedbackCodeSpan(summary))
}
return "Working...\n" + line
}
func sanitizeToolFeedbackCodeSpan(text string) string {
return strings.ReplaceAll(text, "`", "'")
}
func summarizeToolFeedbackArgs(toolName, argsPreview string) string {
argsPreview = strings.TrimSpace(argsPreview)
if argsPreview == "" {
return ""
}
var args map[string]any
if err := json.Unmarshal([]byte(argsPreview), &args); err != nil {
return ""
}
normalizedToolName := strings.ToLower(strings.TrimSpace(toolName))
if strings.Contains(normalizedToolName, "exec") {
if command := firstStringArg(args, "command"); command != "" {
return truncateToolFeedbackSummary(summarizeExecCommand(command))
}
return ""
}
if isFileToolFeedbackTool(normalizedToolName) {
if summary := firstStringArg(args, "path", "file_path", "filepath"); summary != "" {
return truncateToolFeedbackSummary(filepath.Base(summary))
}
}
return ""
}
func isFileToolFeedbackTool(toolName string) bool {
return strings.Contains(toolName, "read_file") ||
strings.Contains(toolName, "write_file") ||
strings.Contains(toolName, "edit_file") ||
strings.Contains(toolName, "append_file") ||
strings.Contains(toolName, "list_dir")
}
func firstStringArg(args map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := args[key]; ok {
if s, ok := value.(string); ok {
if normalized := normalizeToolFeedbackSummary(s); normalized != "" {
return normalized
}
}
}
}
return ""
}
func normalizeToolFeedbackSummary(text string) string {
return redactToolFeedbackSecrets(strings.Join(strings.Fields(text), " "))
}
func truncateToolFeedbackSummary(text string) string {
const maxRunes = 96
runes := []rune(text)
if len(runes) <= maxRunes {
return text
}
return strings.TrimSpace(string(runes[:maxRunes-3])) + "..."
}
func summarizeExecCommand(command string) string {
fields := strings.Fields(command)
for i := 0; i < len(fields); i++ {
token := strings.Trim(fields[i], `"'`)
if token == "" || isShellAssignment(token) {
continue
}
switch token {
case "env", "command", "time", "timeout", "sudo":
continue
case "bash", "sh", "zsh", "fish", "python", "python3", "node", "deno", "bun", "uv", "uvx", "npx":
for j := i + 1; j < len(fields); j++ {
next := strings.Trim(fields[j], `"'`)
if next == "" || strings.HasPrefix(next, "-") || isShellAssignment(next) {
continue
}
return filepath.Base(next)
}
return token
default:
return filepath.Base(token)
}
}
return ""
}
func isShellAssignment(token string) bool {
return strings.Contains(token, "=") && !strings.HasPrefix(token, "/") && !strings.HasPrefix(token, ".")
}
var (
toolFeedbackSecretValuePatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bsk-(?:proj|or-v1)?-[A-Za-z0-9_-]{16,}`),
regexp.MustCompile(`\bAIza[A-Za-z0-9_-]{20,}`),
regexp.MustCompile(`\bmat_[A-Za-z0-9_-]{16,}`),
regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{20,}`),
regexp.MustCompile(`\b(?:ghp|github_pat|glpat|xox[baprs])_[A-Za-z0-9_-]{16,}`),
}
toolFeedbackSecretKVPattern = regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|authorization)\b\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&]+)`)
toolFeedbackSecretFlagPattern = regexp.MustCompile(`(?i)(--(?:api-key|access-token|auth-token|token|secret|password|authorization)(?:=|\s+))("[^"]*"|'[^']*'|[^\s&]+)`)
)
func redactToolFeedbackSecrets(text string) string {
if text == "" {
return ""
}
for _, pattern := range toolFeedbackSecretValuePatterns {
text = pattern.ReplaceAllString(text, "[redacted]")
}
text = toolFeedbackSecretKVPattern.ReplaceAllString(text, "${1}[redacted]")
text = toolFeedbackSecretFlagPattern.ReplaceAllString(text, "${1}[redacted]")
return text
}
// FitToolFeedbackMessage keeps tool feedback within a single outbound message. // FitToolFeedbackMessage keeps tool feedback within a single outbound message.
// It preserves the first line when possible and truncates the explanation body // It preserves the first line when possible and truncates the explanation body
// instead of letting the message be split into multiple chunks. // instead of letting the message be split into multiple chunks.

View file

@ -11,7 +11,7 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
"I will read README.md first to confirm the current project structure.", "I will read README.md first to confirm the current project structure.",
"{\n \"path\": \"README.md\"\n}", "{\n \"path\": \"README.md\"\n}",
) )
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```" want := "🔧 `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 { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
@ -19,7 +19,7 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) { func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}") got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```" want := "🔧 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
if got != want { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
@ -35,12 +35,85 @@ func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) { func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "", "") got := FormatToolFeedbackMessage("read_file", "", "")
want := "\U0001f527 `read_file`" want := "🔧 `read_file`"
if got != want { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
} }
func TestFormatToolFeedbackMessageWithStyle_WorkingSummary(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"read_file",
"I will read README.md first to confirm the current project structure.",
"{\n \"path\": \"README.md\"\n}",
)
want := "Working...\n• tool: `read_file` — `README.md`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsFileBasenameOnly(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"write_file",
"",
"{\"path\":\"/home/user/private/config.json\"}",
)
want := "Working...\n• tool: `write_file` — `config.json`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsExecCommand(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"exec",
"",
"{\n \"action\": \"run\",\n \"command\": \"scripts/gog_me forms add-question FORM --title Name --type paragraph\",\n \"timeout\": 120\n}",
)
want := "Working...\n• tool: `exec` — `gog_me`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryShowsExecScriptNameOnly(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"exec",
"",
"{\"command\":\"OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 bash -lc /home/server/.picoclaw/main/workspace/tmp_add_questions_anya_form_api.sh --api-key sk-or-v1-abcdefghijklmnopqrstuvwxyz0123456789\"}",
)
want := "Working...\n• tool: `exec` — `tmp_add_questions_anya_form_api.sh`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessageWithStyle_WorkingSummarySanitizesCodeSpan(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle("working_summary", "read_file", "", "{\"path\":\"bad`path\"}")
want := "Working...\n• tool: `read_file` — `bad'path`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFormatToolFeedbackMessageWithStyle_WorkingSummaryOmitsNonFileToolArgs(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"web_fetch",
"",
`{"url":"https://example.test/?token=mat_abcdefghijklmnopqrstuvwxyz0123456789"}`,
)
want := "Working...\n• tool: `web_fetch`"
if got != want {
t.Fatalf("FormatToolFeedbackMessageWithStyle() = %q, want %q", got, want)
}
}
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
got := FitToolFeedbackMessage( got := FitToolFeedbackMessage(
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",