diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 263eeb4dd..a4a56b2f6 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4ab6b4542..fd7bd7bb4 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -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