fix(agent): stop message-only tool call loops

This commit is contained in:
mosir 2026-03-05 01:47:03 +08:00
parent aef1e8e8c4
commit 7ff33ad7e6
2 changed files with 105 additions and 0 deletions

View file

@ -1133,11 +1133,28 @@ func (al *AgentLoop) runLLMIteration(
// Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
// If this round only executed message tool calls, stop the loop here.
// This prevents "ack-on-ack" loops where the model keeps calling message.
if areAllToolCallsMessage(normalizedToolCalls) {
break
}
}
return finalContent, iteration, nil
}
func areAllToolCallsMessage(calls []providers.ToolCall) bool {
if len(calls) == 0 {
return false
}
for _, tc := range calls {
if tc.Name != "message" {
return false
}
}
return true
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)

View file

@ -323,6 +323,39 @@ func (m *simpleMockProvider) GetDefaultModel() string {
return "mock-model"
}
type messageOnlyLoopProvider struct {
callCount int
}
func (m *messageOnlyLoopProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
m.callCount++
return &providers.LLMResponse{
ToolCalls: []providers.ToolCall{
{
ID: fmt.Sprintf("tc-%d", m.callCount),
Name: "message",
Arguments: map[string]any{
"content": "Context compressed",
},
Function: &providers.FunctionCall{
Name: "message",
Arguments: `{"content":"Context compressed"}`,
},
},
},
}, nil
}
func (m *messageOnlyLoopProvider) GetDefaultModel() string {
return "mock-message-only-model"
}
// mockCustomTool is a simple mock tool for registration testing
type mockCustomTool struct{}
@ -448,6 +481,61 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
}
}
func TestRunLLMIteration_MessageOnlyToolCallsStopAfterOneRound(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &messageOnlyLoopProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("No default agent found")
}
al.beginRoundForMessageTool(agent)
al.updateToolContexts(agent, "telegram", "chat-1")
_, iteration, err := al.runLLMIteration(
context.Background(),
agent,
[]providers.Message{
{Role: "system", Content: "test system prompt"},
{Role: "user", Content: "压缩上下文"},
},
processOptions{
SessionKey: "test-session",
Channel: "telegram",
ChatID: "chat-1",
SendResponse: false,
},
)
if err != nil {
t.Fatalf("runLLMIteration failed: %v", err)
}
if iteration != 1 {
t.Fatalf("expected runLLMIteration to stop after 1 iteration, got %d", iteration)
}
if provider.callCount != 1 {
t.Fatalf("expected provider to be called once, got %d", provider.callCount)
}
}
// failFirstMockProvider fails on the first N calls with a specific error
type failFirstMockProvider struct {
failures int