This commit is contained in:
Anton Bogdanovich 2026-05-15 11:29:33 +03:00 committed by GitHub
commit 29fc13dcf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 991 additions and 55 deletions

View file

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

View file

@ -66,7 +66,9 @@ Debug logs are server-side only. If you want the agent to send a visible notific
"tool_feedback": {
"enabled": true,
"max_args_length": 300,
"separate_messages": true
"separate_messages": true,
"subagents": true,
"style": "raw"
}
}
}
@ -80,6 +82,40 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
{"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,
"subagents": true,
"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
@ -87,15 +123,19 @@ 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 |
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
| `subagents` | bool | `true` | Also publish visible tool feedback for subagent turns when `enabled` is true |
| `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
Both fields can also be set via environment variables:
These fields can also be set via environment variables:
```bash
PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED=true
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.

View file

@ -49,3 +49,12 @@ func (a *channelManagerAdapter) DismissToolFeedback(
) {
a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx)
}
func (a *channelManagerAdapter) DismissToolFeedbackForSession(
ctx context.Context,
channel, chatID string,
outboundCtx *bus.InboundContext,
sessionKey string,
) {
a.inner.DismissToolFeedbackForSession(ctx, channel, chatID, outboundCtx, sessionKey)
}

View file

@ -261,7 +261,14 @@ func (al *AgentLoop) Run(ctx context.Context) error {
return
}
if continued != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued)
al.publishResponseWithContextIfNeeded(
ctx,
target.Channel,
target.ChatID,
target.SessionKey,
continued,
&m.Context,
)
}
return
}

View file

@ -41,6 +41,14 @@ func (al *AgentLoop) publishResponseOrError(
}
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
al.publishResponseWithContextIfNeeded(ctx, channel, chatID, sessionKey, response, nil)
}
func (al *AgentLoop) publishResponseWithContextIfNeeded(
ctx context.Context,
channel, chatID, sessionKey, response string,
inboundCtx *bus.InboundContext,
) {
if response == "" {
return
}
@ -74,12 +82,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return
}
agent := al.agentForSession(sessionKey)
agentID := ""
if agent != nil {
agentID = agent.ID
}
msg := bus.OutboundMessage{
Context: bus.NewOutboundContext(channel, chatID, ""),
Content: response,
Channel: channel,
ChatID: chatID,
Context: outboundContextFromInbound(inboundCtx, channel, chatID, ""),
AgentID: agentID,
SessionKey: sessionKey,
Content: response,
}
if sessionKey != "" {
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
msg.ContextUsage = computeContextUsage(agent, sessionKey)
}
al.bus.PublishOutbound(ctx, msg)
logger.InfoCF("agent", "Published outbound response",

View file

@ -58,7 +58,14 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
// Publish final response
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
al.publishResponseWithContextIfNeeded(
ctx,
target.Channel,
target.ChatID,
target.SessionKey,
finalResponse,
&initialMsg.Context,
)
}
}

View file

@ -194,6 +194,45 @@ func newTestAgentLoop(
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
}
func TestPublishResponseWithContextIfNeeded_PreservesSessionMetadata(t *testing.T) {
al, _, msgBus, _, cleanup := newTestAgentLoop(t)
defer cleanup()
inboundCtx := &bus.InboundContext{
Channel: "telegram",
ChatID: "-100123",
ChatType: "group",
TopicID: "6",
SenderID: "user-1",
}
al.publishResponseWithContextIfNeeded(
context.Background(),
"telegram",
"-100123",
"session-async-1",
"done",
inboundCtx,
)
select {
case outbound := <-msgBus.OutboundChan():
if outbound.Channel != "telegram" || outbound.ChatID != "-100123" {
t.Fatalf("unexpected outbound target: channel=%q chat=%q", outbound.Channel, outbound.ChatID)
}
if outbound.Context.TopicID != "6" {
t.Fatalf("outbound topic_id = %q, want 6", outbound.Context.TopicID)
}
if outbound.AgentID != routing.DefaultAgentID {
t.Fatalf("outbound agent_id = %q, want %q", outbound.AgentID, routing.DefaultAgentID)
}
if outbound.SessionKey != "session-async-1" {
t.Fatalf("outbound session_key = %q, want session-async-1", outbound.SessionKey)
}
case <-time.After(2 * time.Second):
t.Fatal("expected outbound response")
}
}
func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = t.TempDir()
@ -2124,6 +2163,29 @@ func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
}
}
func TestShouldPublishToolFeedback_DisablesSubagentFeedback(t *testing.T) {
subagents := false
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ToolFeedback = config.ToolFeedbackConfig{
Enabled: true,
Subagents: &subagents,
}
if shouldPublishToolFeedback(cfg, &turnState{
channel: "telegram",
sessionKey: "subturn-1",
}) {
t.Fatal("shouldPublishToolFeedback() = true for disabled subagent feedback, want false")
}
if !shouldPublishToolFeedback(cfg, &turnState{
channel: "telegram",
sessionKey: "chat-1",
}) {
t.Fatal("shouldPublishToolFeedback() = false for main turn, want true")
}
}
type picoInterleavedContentProvider struct {
calls int
}
@ -4043,6 +4105,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) {
ToolFeedback: config.ToolFeedbackConfig{
Enabled: true,
MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
},
},
},
@ -4089,6 +4152,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
ToolFeedback: config.ToolFeedbackConfig{
Enabled: true,
MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
},
},
},
@ -4118,7 +4182,6 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
select {
case outbound := <-msgBus.OutboundChan():
escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`)
if outbound.Channel != "telegram" {
t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram")
}
@ -4128,20 +4191,13 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if outbound.Context.Channel != "telegram" || outbound.Context.ChatID != "chat-1" {
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)
}
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
}
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, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
strings.Contains(outbound.Content, "check tool feedback") ||
strings.Contains(outbound.Content, "\"path\":") {
t.Fatalf("tool feedback content = %q, should only include compact tool names", outbound.Content)
}
if strings.Contains(outbound.Content, "Previous turn explanation") {
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
@ -4353,6 +4409,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
ToolFeedback: config.ToolFeedbackConfig{
Enabled: true,
MaxArgsLength: 300,
Style: utils.ToolFeedbackStyleWorkingSummary,
},
},
},
@ -4383,20 +4440,14 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
select {
case outbound := <-msgBus.OutboundChan():
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)
}
if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content)
}
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, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
if strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) ||
strings.Contains(outbound.Content, "check reasoning fallback") ||
strings.Contains(outbound.Content, "\"path\":") ||
strings.Contains(outbound.Content, escapedHeartbeatFile) {
t.Fatalf("tool feedback content = %q, should only include compact tool names", outbound.Content)
}
if strings.Contains(outbound.Content, "Read README.md first") {
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)

View file

@ -178,7 +178,52 @@ func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
return false
}
return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled()
if cfg == nil || !cfg.Agents.Defaults.IsToolFeedbackEnabled() {
return false
}
if strings.HasPrefix(strings.TrimSpace(ts.sessionKey), "subturn-") &&
!cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
return false
}
return true
}
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 {

View file

@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
func newHookTestLoop(
@ -809,6 +810,7 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
defer cleanup()
al.cfg.Agents.Defaults.ToolFeedback.Enabled = true
al.cfg.Agents.Defaults.ToolFeedback.Style = utils.ToolFeedbackStyleWorkingSummary
al.RegisterTool(&echoTextTool{})
al.RegisterTool(&echoTextRewrittenTool{})
if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil {
@ -835,10 +837,10 @@ func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) {
select {
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)
}
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)
}
case <-time.After(2 * time.Second):

View file

@ -51,4 +51,16 @@ type ChannelManager interface {
// outboundCtx carries topic/thread info needed for channels that use
// scoped tracker keys (e.g., Telegram forum topics); may be nil.
DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext)
// DismissToolFeedbackForSession clears a session-scoped tool feedback
// message. This is used for background sub-turns whose progress messages
// are visible in the originating chat, but whose final result is delivered
// asynchronously through the parent turn instead of as a direct channel
// response.
DismissToolFeedbackForSession(
ctx context.Context,
channel, chatID string,
outboundCtx *bus.InboundContext,
sessionKey string,
)
}

View file

@ -174,11 +174,21 @@ toolLoop:
tc,
messages,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
toolName,
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()
@ -461,11 +471,21 @@ toolLoop:
tc,
messages,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
feedbackMsg := utils.FormatToolFeedbackMessageWithStyle(
al.cfg.Agents.Defaults.GetToolFeedbackStyle(),
toolName,
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()

View file

@ -462,6 +462,17 @@ func spawnSubTurn(
// Result Delivery Strategy (Async vs Sync)
if cfg.Async {
if al != nil && al.channelManager != nil && childTS.channel != "" {
dismissCtx, dismissCancel := context.WithTimeout(context.Background(), 5*time.Second)
al.channelManager.DismissToolFeedbackForSession(
dismissCtx,
childTS.channel,
childTS.chatID,
childTS.opts.Dispatch.InboundContext,
childID,
)
dismissCancel()
}
deliverSubTurnResult(al, parentTS, childID, result)
}

View file

@ -191,6 +191,15 @@ func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bu
return strings.TrimSpace(chatID)
}
func candidateToolFeedbackMessageChatIDs(raw, resolved string) []string {
raw = strings.TrimSpace(raw)
resolved = strings.TrimSpace(resolved)
if raw == "" || raw == resolved {
return []string{resolved}
}
return []string{resolved, raw}
}
func dismissTrackedToolFeedbackMessage(
ctx context.Context,
ch Channel,
@ -210,6 +219,38 @@ func dismissTrackedToolFeedbackMessage(
}
}
func dismissTrackedToolFeedbackMessageForSession(
ctx context.Context,
ch Channel,
chatID string,
outboundCtx *bus.InboundContext,
sessionKey string,
) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
return
}
resolvedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok {
for _, candidate := range candidateToolFeedbackMessageChatIDs(chatID, resolvedChatID) {
if candidate == "" {
continue
}
cleaner.DismissToolFeedbackMessage(ctx, candidate+"#session:"+sessionKey)
}
return
}
if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
for _, candidate := range candidateToolFeedbackMessageChatIDs(chatID, resolvedChatID) {
if candidate == "" {
continue
}
tracker.ClearToolFeedbackMessage(candidate + "#session:" + sessionKey)
}
}
}
func clearTrackedToolFeedbackMessage(
ch Channel,
chatID string,
@ -239,6 +280,16 @@ func (m *Manager) DismissToolFeedback(
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
}
func (m *Manager) DismissToolFeedbackForSession(
ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, sessionKey string,
) {
ch, ok := m.GetChannel(channelName)
if !ok {
return
}
dismissTrackedToolFeedbackMessageForSession(ctx, ch, chatID, outboundCtx, sessionKey)
}
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
prepared := strings.TrimSpace(content)
if prepared == "" {

View file

@ -919,6 +919,7 @@ type mockMessageEditor struct {
recordedContent string
clearedChatID string
dismissedChatID string
dismissedChatIDs []string
}
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
@ -937,6 +938,7 @@ func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) {
func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) {
m.dismissedChatID = chatID
m.dismissedChatIDs = append(m.dismissedChatIDs, chatID)
}
func (m *mockMessageEditor) FinalizeToolFeedbackMessage(
@ -979,6 +981,34 @@ func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
return chatID
}
func TestDismissToolFeedbackForSession_UsesResolvedTopicScopedKey(t *testing.T) {
m := newTestManager()
ch := &mockResolvedToolFeedbackEditor{
resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string {
if chatID != "-100123" {
t.Fatalf("chatID = %q, want -100123", chatID)
}
if outboundCtx == nil || outboundCtx.TopicID != "6" {
t.Fatalf("unexpected outbound context: %+v", outboundCtx)
}
return "-100123/6"
},
}
m.channels["telegram"] = ch
m.DismissToolFeedbackForSession(
context.Background(),
"telegram",
"-100123",
&bus.InboundContext{Channel: "telegram", ChatID: "-100123", TopicID: "6"},
"subturn-1",
)
if len(ch.dismissedChatIDs) == 0 || ch.dismissedChatIDs[0] != "-100123/6#session:subturn-1" {
t.Fatalf("dismissed chatIDs = %v, want topic-scoped session key first", ch.dismissedChatIDs)
}
}
type mockPreparedToolFeedbackEditor struct {
mockMessageEditor
prepareFn func(content string) string

View file

@ -233,7 +233,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
if isToolFeedback {
toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096)
}
trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
trackedChatID := telegramToolFeedbackTrackerKey(msg)
if isToolFeedback {
if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled {
if err != nil {
@ -441,6 +441,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
useMarkdownV2 := c.tgCfg.UseMarkdownV2
chatID = telegramToolFeedbackDeliveryChatID(chatID)
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
@ -497,6 +498,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
// DeleteMessage implements channels.MessageDeleter.
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
chatID = telegramToolFeedbackDeliveryChatID(chatID)
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
@ -518,6 +520,23 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
}
func telegramToolFeedbackTrackerKey(msg bus.OutboundMessage) string {
key := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
sessionKey := strings.TrimSpace(msg.SessionKey)
if key == "" || sessionKey == "" {
return key
}
return key + "#session:" + sessionKey
}
func telegramToolFeedbackDeliveryChatID(chatID string) string {
chatID = strings.TrimSpace(chatID)
if idx := strings.Index(chatID, "#session:"); idx >= 0 {
return strings.TrimSpace(chatID[:idx])
}
return chatID
}
func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
if c.progress == nil {
return "", false
@ -559,7 +578,7 @@ func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context,
return
}
c.ClearToolFeedbackMessage(chatID)
_ = c.DeleteMessage(ctx, chatID, messageID)
_ = c.DeleteMessage(ctx, telegramToolFeedbackDeliveryChatID(chatID), messageID)
}
func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
@ -572,7 +591,7 @@ func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
if !ok || editFn == nil {
return nil, false
}
if err := editFn(ctx, chatID, msgID, content); err != nil {
if err := editFn(ctx, telegramToolFeedbackDeliveryChatID(chatID), msgID, content); err != nil {
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
return nil, false
}

View file

@ -375,6 +375,115 @@ func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T)
assert.True(t, ok, "tool feedback in the original topic should remain tracked")
}
func TestSend_FinalReplyDoesNotFinalizeDifferentSessionToolFeedback(t *testing.T) {
nextMessageID := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
nextMessageID++
return successResponseWithMessageID(t, nextMessageID), nil
},
}
ch := newTestChannel(t, caller)
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
baseCtx := bus.InboundContext{
Channel: "telegram",
ChatID: "-1001234567890",
TopicID: "42",
}
_, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890",
SessionKey: "subturn-1",
Content: "Working...\n• tool: `read_file`",
Context: bus.InboundContext{
Channel: "telegram",
ChatID: "-1001234567890",
TopicID: "42",
Raw: map[string]string{
"message_kind": "tool_feedback",
},
},
})
require.NoError(t, err)
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890",
SessionKey: "main-session",
Content: "test",
Context: baseCtx,
})
require.NoError(t, err)
require.Len(t, caller.calls, 2)
assert.Equal(t, []string{"2"}, ids)
assert.Contains(t, caller.calls[1].URL, "sendMessage")
assert.NotContains(t, caller.calls[1].URL, "editMessageText")
msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42#session:subturn-1")
require.True(t, ok, "subturn tool feedback should remain tracked")
assert.Equal(t, "1", msgID)
}
func TestSend_SessionScopedToolFeedbackUpdatesExistingTelegramMessage(t *testing.T) {
nextMessageID := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
nextMessageID++
return successResponseWithMessageID(t, nextMessageID), nil
},
}
ch := newTestChannel(t, caller)
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
baseCtx := bus.InboundContext{
Channel: "telegram",
ChatID: "-1001234567890",
TopicID: "42",
Raw: map[string]string{
"message_kind": "tool_feedback",
},
}
_, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890",
SessionKey: "subturn-1",
Content: "Working...\n• tool: `read_file`",
Context: baseCtx,
})
require.NoError(t, err)
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890",
SessionKey: "subturn-1",
Content: "Working...\n• tool: `read_file`\n• tool: `mcp_gpt_researcher_deep_research`",
Context: baseCtx,
})
require.NoError(t, err)
require.Equal(t, []string{"1"}, ids)
require.Len(t, caller.calls, 2)
assert.Contains(t, caller.calls[1].URL, "editMessageText")
assert.NotContains(t, caller.calls[1].URL, "%23session")
assert.NotContains(t, caller.calls[1].URL, "#session")
}
func TestDismissToolFeedbackMessage_SessionScopedKeyDeletesTelegramMessage(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
ch.RecordToolFeedbackMessage("-1001234567890/42#session:subturn-1", "7", "Working...\n• tool: `read_file`")
ch.DismissToolFeedbackMessage(context.Background(), "-1001234567890/42#session:subturn-1")
require.Len(t, caller.calls, 1)
assert.Contains(t, caller.calls[0].URL, "deleteMessage")
assert.NotContains(t, caller.calls[0].URL, "%23session")
assert.NotContains(t, caller.calls[0].URL, "#session")
_, ok := ch.currentToolFeedbackMessage("-1001234567890/42#session:subturn-1")
assert.False(t, ok)
}
func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
ch := newTestChannel(t, &stubCaller{
callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {
@ -442,6 +551,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) {
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
// 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 workingSummaryToolFeedbackAnimationInterval = 10 * time.Second
const maxMergedToolFeedbackLines = 8
const initialToolFeedbackAnimationFrame = ""
@ -122,13 +124,17 @@ func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content strin
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 {
a.Record(chatID, msgID, baseContent)
return "", true, err
}
a.Record(chatID, msgID, content)
a.Record(chatID, msgID, mergedContent)
return msgID, true, nil
}
@ -163,7 +169,7 @@ func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
defer close(entry.done)
ticker := time.NewTicker(toolFeedbackAnimationInterval)
ticker := time.NewTicker(toolFeedbackAnimationIntervalFor(entry.baseContent))
defer ticker.Stop()
frameIdx := 1
@ -227,6 +233,77 @@ func appendToolFeedbackFrame(firstLine, frame string) string {
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 == "" || isWorkingSummaryHeader(line) {
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:]
}
header := workingSummaryHeader(previous)
if candidate := workingSummaryHeader(next); candidate != "" {
header = candidate
}
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")
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 {
if isWorkingSummaryToolFeedback(content) {
return workingSummaryToolFeedbackAnimationInterval
}
return toolFeedbackAnimationInterval
}
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
if entry == nil {
return

View file

@ -75,16 +75,17 @@ func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
if messageID != "msg-1" {
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)
}
return nil
})
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 {
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) {
editErr := errors.New("edit failed")
animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error {
@ -103,9 +149,9 @@ func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
})
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 {
t.Fatal("Update() handled = false, want true")
}
@ -119,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")
}
}

View file

@ -370,9 +370,11 @@ type SubTurnConfig struct {
}
type ToolFeedbackConfig struct {
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"`
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
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"`
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
Subagents *bool `json:"subagents,omitempty"`
Style string `json:"style,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_STYLE"`
}
type AgentDefaults struct {
@ -425,6 +427,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
return d.ToolFeedback.Enabled
}
// IsSubagentToolFeedbackEnabled returns true when subagent turns should publish
// visible tool feedback. It defaults to true for backward compatibility when
// tool_feedback itself is enabled.
func (d *AgentDefaults) IsSubagentToolFeedbackEnabled() bool {
return d.ToolFeedback.Subagents == nil || *d.ToolFeedback.Subagents
}
// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback
// update should be sent as its own chat message instead of editing a single
// in-place progress message.
@ -432,6 +441,10 @@ func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
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.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {

View file

@ -1098,9 +1098,15 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
}
if !cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
t.Fatal("DefaultConfig().Agents.Defaults.IsSubagentToolFeedbackEnabled() should default to true")
}
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
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) {
@ -1123,9 +1129,55 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
"agents.defaults.tool_feedback.enabled should remain false when unset in config file",
)
}
if !cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
t.Fatal("agents.defaults.tool_feedback.subagents should default to true when unset")
}
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
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_ToolFeedbackSubagentsFalse(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,"subagents":false}}}}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Agents.Defaults.IsSubagentToolFeedbackEnabled() {
t.Fatal("agents.defaults.tool_feedback.subagents = true, want false")
}
}
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {

View file

@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
)
@ -30,6 +32,8 @@ func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) st
return strings.TrimSpace(buf.String())
}
const ToolFeedbackStyleWorkingSummary = "working_summary"
// 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.
@ -57,6 +61,187 @@ func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string
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 {
return FormatToolFeedbackMessageWithStyleAndTitle(style, "", toolName, explanation, argsPreview)
}
func FormatToolFeedbackMessageWithStyleAndTitle(
style, title, toolName, explanation, argsPreview string,
) string {
if strings.EqualFold(strings.TrimSpace(style), ToolFeedbackStyleWorkingSummary) {
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 header
}
line := fmt.Sprintf("• tool: `%s`", sanitizeToolFeedbackCodeSpan(toolName))
if summary := summarizeToolFeedbackArgs(toolName, argsPreview); summary != "" {
line += fmt.Sprintf(" — `%s`", sanitizeToolFeedbackCodeSpan(summary))
}
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 {
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(summarizeFilePath(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 summarizeFilePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
cleaned := filepath.Clean(path)
slashed := filepath.ToSlash(cleaned)
if idx := strings.LastIndex(slashed, "/workspace/"); idx >= 0 {
relative := strings.TrimPrefix(slashed[idx+len("/workspace/"):], "/")
if relative != "" && relative != "." {
return relative
}
}
if !filepath.IsAbs(cleaned) {
return slashed
}
return filepath.Base(cleaned)
}
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.
// It preserves the first line when possible and truncates the explanation body
// 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.",
"{\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 {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
@ -19,7 +19,7 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
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```"
want := "🔧 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
}
@ -35,12 +35,112 @@ func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "", "")
want := "\U0001f527 `read_file`"
want := "🔧 `read_file`"
if 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 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",
"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_WorkingSummaryShowsWorkspaceRelativeFile(t *testing.T) {
got := FormatToolFeedbackMessageWithStyle(
"working_summary",
"read_file",
"",
"{\"path\":\"/home/server/.picoclaw/spouse/workspace/memory/MEMORY.md\"}",
)
want := "Working...\n• tool: `read_file` — `memory/MEMORY.md`"
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) {
got := FitToolFeedbackMessage(
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",