fix(tool-feedback): unify fallback and single-message delivery

This commit is contained in:
lxowalle 2026-04-17 14:49:56 +08:00
parent 04733d9f37
commit 9ea8e6167a
13 changed files with 139 additions and 42 deletions

View file

@ -54,7 +54,7 @@ Discord can show three different kinds of "working" feedback:
```text ```text
🔧 `web_search` 🔧 `web_search`
{"query":"picoclaw release notes"} Checking the latest PicoClaw release notes before I answer.
``` ```
If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config.

View file

@ -851,13 +851,10 @@ func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.Out
return msg return msg
} }
func previousAssistantContent(messages []providers.Message) string { func latestUserContent(messages []providers.Message) string {
for i := len(messages) - 1; i >= 0; i-- { for i := len(messages) - 1; i >= 0; i-- {
msg := messages[i] msg := messages[i]
if msg.Role == "user" { if msg.Role != "user" {
break
}
if msg.Role != "assistant" {
continue continue
} }
if content := strings.TrimSpace(msg.Content); content != "" { if content := strings.TrimSpace(msg.Content); content != "" {
@ -883,7 +880,10 @@ func toolFeedbackExplanationFromResponse(
explanation = strings.TrimSpace(response.ReasoningContent) explanation = strings.TrimSpace(response.ReasoningContent)
} }
if explanation == "" { if explanation == "" {
explanation = previousAssistantContent(messages) explanation = latestUserContent(messages)
if explanation != "" {
explanation = utils.ToolFeedbackContinuationHint + ": " + explanation
}
} }
return utils.Truncate(explanation, maxLen) return utils.Truncate(explanation, maxLen)
} }
@ -2738,6 +2738,12 @@ turnLoop:
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments) argumentsJSON, _ := json.Marshal(tc.Arguments)
extraContent := tc.ExtraContent extraContent := tc.ExtraContent
if strings.TrimSpace(toolFeedbackExplanation) != "" {
if extraContent == nil {
extraContent = &providers.ExtraContent{}
}
extraContent.ToolFeedbackExplanation = toolFeedbackExplanation
}
thoughtSignature := "" thoughtSignature := ""
if tc.Function != nil { if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature thoughtSignature = tc.Function.ThoughtSignature

View file

@ -24,6 +24,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"
) )
type fakeChannel struct{ id string } type fakeChannel struct{ id string }
@ -1827,7 +1828,7 @@ func TestToolFeedbackExplanationFromResponse_FallsBackToReasoningContent(t *test
} }
} }
func TestToolFeedbackExplanationFromResponse_UsesPreviousAssistantContentAsLastResort(t *testing.T) { func TestToolFeedbackExplanationFromResponse_UsesLatestUserContentAsLastResort(t *testing.T) {
response := &providers.LLMResponse{ response := &providers.LLMResponse{
Content: "", Content: "",
ReasoningContent: "", ReasoningContent: "",
@ -1835,12 +1836,14 @@ func TestToolFeedbackExplanationFromResponse_UsesPreviousAssistantContentAsLastR
messages := []providers.Message{ messages := []providers.Message{
{Role: "user", Content: "check file"}, {Role: "user", Content: "check file"},
{Role: "assistant", Content: "Previous turn explanation"}, {Role: "assistant", Content: "Previous turn explanation"},
{Role: "user", Content: "Inspect README.md and update the config example."},
{Role: "tool", Content: "tool output", ToolCallID: "call_1"}, {Role: "tool", Content: "tool output", ToolCallID: "call_1"},
} }
got := toolFeedbackExplanationFromResponse(response, messages, 300) got := toolFeedbackExplanationFromResponse(response, messages, 300)
if got != "Previous turn explanation" { want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example."
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want previous assistant content", got) if got != want {
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got)
} }
} }
@ -3744,8 +3747,14 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if !strings.Contains(outbound.Content, "`read_file`") { if !strings.Contains(outbound.Content, "`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, "Why this tool should be executed") { if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) {
t.Fatalf("tool feedback content = %q, want no fallback explanation", outbound.Content) 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, "Previous turn explanation") {
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
} }
if outbound.AgentID != "main" { if outbound.AgentID != "main" {
t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID)

View file

@ -26,6 +26,7 @@ import (
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
) )
const ( const (
@ -820,14 +821,16 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
for _, chunk := range markerChunks { for _, chunk := range markerChunks {
chunks = append(chunks, splitByLength(chunk, maxLen)...) chunkMsg := msg
chunkMsg.Content = chunk
chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...)
} }
} }
} }
// Step 2: Fallback to length-based splitting if no chunks from marker // Step 2: Fallback to length-based splitting if no chunks from marker
if len(chunks) == 0 { if len(chunks) == 0 {
chunks = splitByLength(msg.Content, maxLen) chunks = splitOutboundMessageContent(msg, maxLen)
} }
// Step 3: Send all chunks // Step 3: Send all chunks
@ -842,12 +845,16 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
} }
} }
// splitByLength splits content by maxLen if needed, otherwise returns single chunk. // splitOutboundMessageContent splits regular outbound content by maxLen, but
func splitByLength(content string, maxLen int) []string { // keeps tool feedback in a single message by truncating the explanation body.
if maxLen > 0 && len([]rune(content)) > maxLen { func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string {
return SplitMessage(content, maxLen) if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
if outboundMessageIsToolFeedback(msg) {
return []string{utils.FitToolFeedbackMessage(msg.Content, maxLen)}
}
return SplitMessage(msg.Content, maxLen)
} }
return []string{content} return []string{msg.Content}
} }
// sendWithRetry sends a message through the channel with rate limiting and // sendWithRetry sends a message through the channel with rate limiting and
@ -1311,13 +1318,16 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro
if mlp, ok := w.ch.(MessageLengthProvider); ok { if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength() maxLen = mlp.MaxMessageLength()
} }
if maxLen > 0 && len([]rune(msg.Content)) > maxLen { if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 {
for _, chunk := range SplitMessage(msg.Content, maxLen) { for _, chunk := range chunks {
chunkMsg := msg chunkMsg := msg
chunkMsg.Content = chunk chunkMsg.Content = chunk
m.sendWithRetry(ctx, channelName, w, chunkMsg) m.sendWithRetry(ctx, channelName, w, chunkMsg)
} }
} else { } else {
if len(chunks) == 1 {
msg.Content = chunks[0]
}
m.sendWithRetry(ctx, channelName, w, msg) m.sendWithRetry(ctx, channelName, w, msg)
} }
return nil return nil

View file

@ -13,6 +13,7 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/utils"
) )
// mockChannel is a test double that delegates Send to a configurable function. // mockChannel is a test double that delegates Send to a configurable function.
@ -907,6 +908,30 @@ func TestPreSendMedia_DismissesTrackedMessage(t *testing.T) {
} }
} }
func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) {
msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.",
Context: bus.InboundContext{
Channel: "test",
ChatID: "123",
Raw: map[string]string{
"message_kind": "tool_feedback",
},
},
})
chunks := splitOutboundMessageContent(msg, 40)
if len(chunks) != 1 {
t.Fatalf("len(chunks) = %d, want 1", len(chunks))
}
want := utils.FitToolFeedbackMessage(msg.Content, 40)
if chunks[0] != want {
t.Fatalf("chunk = %q, want %q", chunks[0], want)
}
}
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
m := newTestManager() m := newTestManager()

View file

@ -286,7 +286,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int {
return DefaultMaxMediaSize return DefaultMaxMediaSize
} }
// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. // GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages.
func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int {
if d.ToolFeedback.MaxArgsLength > 0 { if d.ToolFeedback.MaxArgsLength > 0 {
return d.ToolFeedback.MaxArgsLength return d.ToolFeedback.MaxArgsLength

View file

@ -11,7 +11,8 @@ type ToolCall struct {
} }
type ExtraContent struct { type ExtraContent struct {
Google *GoogleExtra `json:"google,omitempty"` Google *GoogleExtra `json:"google,omitempty"`
ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"`
} }
type GoogleExtra struct { type GoogleExtra struct {

View file

@ -5,6 +5,8 @@ import (
"strings" "strings"
) )
const ToolFeedbackContinuationHint = "Continuing the current task."
// FormatToolFeedbackMessage renders the model-provided explanation for why a // FormatToolFeedbackMessage renders the model-provided explanation for why a
// tool is being executed. When the model does not provide one, it keeps only // tool is being executed. When the model does not provide one, it keeps only
// the tool line and does not expose raw arguments or fallback text. // the tool line and does not expose raw arguments or fallback text.
@ -21,3 +23,35 @@ func FormatToolFeedbackMessage(toolName, explanation string) string {
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation)
} }
// 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.
func FitToolFeedbackMessage(content string, maxLen int) string {
content = strings.TrimSpace(content)
if content == "" || maxLen <= 0 {
return ""
}
if len([]rune(content)) <= maxLen {
return content
}
firstLine, rest, hasRest := strings.Cut(content, "\n")
firstLine = strings.TrimSpace(firstLine)
rest = strings.TrimSpace(rest)
if !hasRest || rest == "" {
return Truncate(firstLine, maxLen)
}
if len([]rune(firstLine)) >= maxLen {
return Truncate(firstLine, maxLen)
}
remaining := maxLen - len([]rune(firstLine)) - 1
if remaining <= 0 {
return Truncate(firstLine, maxLen)
}
return firstLine + "\n" + Truncate(rest, remaining)
}

View file

@ -25,3 +25,19 @@ func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
} }
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
got := FitToolFeedbackMessage("\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", 40)
want := "\U0001f527 `read_file`\nRead README.md first to..."
if got != want {
t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
}
}
func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) {
got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10)
want := "\U0001f527 `read..."
if got != want {
t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want)
}
}

View file

@ -529,31 +529,23 @@ func visibleAssistantToolSummaryMessages(
messages := make([]sessionChatMessage, 0, len(toolCalls)) messages := make([]sessionChatMessage, 0, len(toolCalls))
for _, tc := range toolCalls { for _, tc := range toolCalls {
name := tc.Name name := tc.Name
argsJSON := ""
if tc.Function != nil { if tc.Function != nil {
if name == "" { if name == "" {
name = tc.Function.Name name = tc.Function.Name
} }
argsJSON = tc.Function.Arguments
} }
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
continue continue
} }
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { explanation := ""
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { if tc.ExtraContent != nil {
argsJSON = string(encodedArgs) explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation)
}
}
argsPreview := strings.TrimSpace(argsJSON)
if argsPreview == "" {
argsPreview = "{}"
} }
messages = append(messages, sessionChatMessage{ messages = append(messages, sessionChatMessage{
Role: "assistant", Role: "assistant",
Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(explanation, toolFeedbackMaxArgsLength)),
}) })
} }

View file

@ -629,6 +629,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
} }
argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`
explanation := "Read README.md first to confirm the current project structure before editing the config example."
sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" sessionKey := picoSessionPrefix + "detail-tool-summary-max-args"
err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"})
if err != nil { if err != nil {
@ -643,6 +644,9 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
Name: "read_file", Name: "read_file",
Arguments: argsJSON, Arguments: argsJSON,
}, },
ExtraContent: &providers.ExtraContent{
ToolFeedbackExplanation: explanation,
},
}}, }},
}) })
if err != nil { if err != nil {
@ -675,7 +679,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
} }
wantPreview := utils.Truncate(argsJSON, 20) wantPreview := utils.Truncate(explanation, 20)
if !strings.Contains(resp.Messages[1].Content, wantPreview) { if !strings.Contains(resp.Messages[1].Content, wantPreview) {
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
} }

View file

@ -592,9 +592,9 @@
"split_on_marker": "Chatty Mode", "split_on_marker": "Chatty Mode",
"split_on_marker_hint": "Split long messages into short ones like real human chatting.", "split_on_marker_hint": "Split long messages into short ones like real human chatting.",
"tool_feedback_enabled": "Tool Feedback", "tool_feedback_enabled": "Tool Feedback",
"tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.", "tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.",
"tool_feedback_max_args_length": "Tool Feedback Args Preview Length", "tool_feedback_max_args_length": "Tool Feedback Length",
"tool_feedback_max_args_length_hint": "Maximum number of argument characters shown in each tool feedback message. Set to 0 to use the default.", "tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.",
"exec_enabled": "Allow Commands", "exec_enabled": "Allow Commands",
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.", "exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
"allow_remote": "Allow Remote Commands", "allow_remote": "Allow Remote Commands",

View file

@ -592,9 +592,9 @@
"split_on_marker": "连续短消息", "split_on_marker": "连续短消息",
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
"tool_feedback_enabled": "工具反馈", "tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览", "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明",
"tool_feedback_max_args_length": "工具反馈参数预览长度", "tool_feedback_max_args_length": "工具反馈长度",
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值", "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值",
"exec_enabled": "允许命令执行", "exec_enabled": "允许命令执行",
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行", "exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
"allow_remote": "允许远程命令执行", "allow_remote": "允许远程命令执行",