fix(tool-feedback): dedupe duplicate content and keep full explanations

This commit is contained in:
lc6464
2026-04-26 00:40:55 +08:00
parent 5cd10b594a
commit 6d04d15ce0
14 changed files with 288 additions and 54 deletions
+8 -5
View File
@@ -160,7 +160,14 @@ func (al *AgentLoop) publishPicoToolCallInterim(
return
}
if strings.TrimSpace(content) != "" {
visibleToolCalls := utils.BuildVisibleToolCalls(
toolCalls,
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
duplicateToolCallContent := len(visibleToolCalls) > 0 &&
utils.ToolCallExplanationDuplicatesContent(content, toolCalls)
if strings.TrimSpace(content) != "" && !duplicateToolCallContent {
pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second)
err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content))
pubCancel()
@@ -175,10 +182,6 @@ func (al *AgentLoop) publishPicoToolCallInterim(
}
}
visibleToolCalls := utils.BuildVisibleToolCalls(
toolCalls,
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
if len(visibleToolCalls) == 0 {
return
}
+71 -19
View File
@@ -1892,7 +1892,7 @@ func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
got := toolFeedbackExplanationFromResponse(response, messages, 300)
got := toolFeedbackExplanationFromResponse(response, messages)
if got != "Read README.md first" {
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got)
}
@@ -1936,7 +1936,7 @@ func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
got := toolFeedbackExplanationFromResponse(response, messages, 300)
got := toolFeedbackExplanationFromResponse(response, messages)
if got != "Read README.md first to confirm the current project structure." {
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got)
}
@@ -1963,8 +1963,8 @@ func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *t
},
}
got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300)
got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300)
got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil)
got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil)
if got1 != "Read README.md first." {
t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1)
}
@@ -1993,7 +1993,7 @@ func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanati
{Role: "user", Content: "inspect the config and update the example"},
}
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300)
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages)
want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example"
if got != want {
t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want)
@@ -2012,13 +2012,31 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi
{Role: "tool", Content: "tool output", ToolCallID: "call_1"},
}
got := toolFeedbackExplanationFromResponse(response, messages, 300)
got := toolFeedbackExplanationFromResponse(response, messages)
want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example."
if got != want {
t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got)
}
}
func TestToolFeedbackExplanationForToolCall_DoesNotTruncateLongExplanation(t *testing.T) {
explanation := "Read README.md first to confirm the current project structure before editing the config example."
response := &providers.LLMResponse{
ToolCalls: []providers.ToolCall{{
ID: "call_1",
Name: "read_file",
ExtraContent: &providers.ExtraContent{
ToolFeedbackExplanation: explanation,
},
}},
}
got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil)
if got != explanation {
t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want full explanation", got)
}
}
func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) {
got := toolFeedbackArgsPreview(map[string]any{
"path": "README.md",
@@ -2064,6 +2082,43 @@ func (m *picoInterleavedContentProvider) GetDefaultModel() string {
return "pico-interleaved-content-model"
}
type picoDistinctToolCallContentProvider struct {
calls int
}
func (m *picoDistinctToolCallContentProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
m.calls++
if m.calls == 1 {
return &providers.LLMResponse{
Content: "intermediate model text",
ToolCalls: []providers.ToolCall{{
ID: "call_tool_limit_test",
Type: "function",
Name: "tool_limit_test_tool",
Arguments: map[string]any{"value": "x"},
ExtraContent: &providers.ExtraContent{
ToolFeedbackExplanation: "Read the file before replying.",
},
}},
}, nil
}
return &providers.LLMResponse{
Content: "final model text",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *picoDistinctToolCallContentProvider) GetDefaultModel() string {
return "pico-distinct-tool-call-content-model"
}
type toolLimitOnlyProvider struct{}
func (m *toolLimitOnlyProvider) Chat(
@@ -4398,7 +4453,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t
}
msgBus := bus.NewMessageBus()
provider := &picoInterleavedContentProvider{}
provider := &picoDistinctToolCallContentProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.GetRegistry().GetDefaultAgent()
@@ -4562,7 +4617,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
outputs := make([]bus.OutboundMessage, 0, 3)
deadline := time.After(2 * time.Second)
for len(outputs) < 3 {
for len(outputs) < 2 {
select {
case outbound := <-msgBus.OutboundChan():
outputs = append(outputs, outbound)
@@ -4571,20 +4626,17 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
}
}
if outputs[0].Content != "intermediate model text" {
t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text")
if outputs[0].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls {
t.Fatalf("first outbound = %+v, want tool_calls message", outputs[0])
}
if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls {
t.Fatalf("second outbound = %+v, want tool_calls message", outputs[1])
if outputs[0].Content != "" {
t.Fatalf("first outbound content = %q, want empty tool_calls content", outputs[0].Content)
}
if outputs[1].Content != "" {
t.Fatalf("second outbound content = %q, want empty tool_calls content", outputs[1].Content)
if !strings.Contains(outputs[0].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") {
t.Fatalf("first outbound tool_calls = %q, want tool name", outputs[0].Context.Raw[metadataKeyToolCalls])
}
if !strings.Contains(outputs[1].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") {
t.Fatalf("second outbound tool_calls = %q, want tool name", outputs[1].Context.Raw[metadataKeyToolCalls])
}
if outputs[2].Content != "final model text" {
t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text")
if outputs[1].Content != "final model text" {
t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text")
}
runCancel()
+4 -6
View File
@@ -115,7 +115,6 @@ func latestUserContent(messages []providers.Message) string {
func toolFeedbackExplanationFromResponse(
response *providers.LLMResponse,
messages []providers.Message,
maxLen int,
) string {
if response == nil {
return ""
@@ -127,7 +126,7 @@ func toolFeedbackExplanationFromResponse(
if explanation == "" {
explanation = toolFeedbackExplanationFromMessages(messages)
}
return utils.Truncate(explanation, maxLen)
return explanation
}
func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string {
@@ -146,22 +145,21 @@ func toolFeedbackExplanationForToolCall(
response *providers.LLMResponse,
toolCall providers.ToolCall,
messages []providers.Message,
maxLen int,
) string {
if toolCall.ExtraContent != nil {
if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" {
return utils.Truncate(explanation, maxLen)
return explanation
}
}
if response == nil {
return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen)
return toolFeedbackExplanationFromMessages(messages)
}
explanation := strings.TrimSpace(response.Content)
if explanation == "" {
explanation = toolFeedbackExplanationFromMessages(messages)
}
return utils.Truncate(explanation, maxLen)
return explanation
}
func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
-2
View File
@@ -86,7 +86,6 @@ toolLoop:
exec.response,
tc,
messages,
toolFeedbackMaxLen,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
toolName,
@@ -368,7 +367,6 @@ toolLoop:
exec.response,
tc,
messages,
toolFeedbackMaxLen,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
toolName,
-1
View File
@@ -478,7 +478,6 @@ func (p *Pipeline) CallLLM(
exec.response,
tc,
exec.messages,
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(),
)
extraContent := tc.ExtraContent
if strings.TrimSpace(toolFeedbackExplanation) != "" {