From 67dc3cab4dcf3b04b2c282b098ba61f4e322b2c8 Mon Sep 17 00:00:00 2001 From: lpf Date: Thu, 19 Feb 2026 15:50:25 +0800 Subject: [PATCH] fix loop --- pkg/agent/loop.go | 47 +++++++++++++++++ pkg/agent/loop_toolloop_test.go | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e5bdec8..6612699 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1764,6 +1764,38 @@ func containsAnySubstring(text string, values ...string) bool { return false } +func hasToolMessages(messages []providers.Message) bool { + for _, m := range messages { + if strings.EqualFold(strings.TrimSpace(m.Role), "tool") { + return true + } + } + return false +} + +func shouldRetryAfterDeferralNoTools(content string, iteration int, alreadyRetried bool, hasToolOutput bool, systemMode bool) bool { + if systemMode || alreadyRetried || hasToolOutput { + return false + } + // Only apply on first planning turn to preserve direct-answer behavior for normal QA. + if iteration > 1 { + return false + } + lower := strings.ToLower(strings.TrimSpace(content)) + if lower == "" { + return false + } + waitCue := containsAnySubstring(lower, + "请稍等", "稍等", "等一下", "我先", "先查看", "需要先查看", "先检查", + "please wait", "wait a moment", "let me check", "i need to check", "i'll check", "checking", + ) + verifyCue := containsAnySubstring(lower, + "查看", "检查", "确认", "工作区", "状态", + "check", "inspect", "verify", "workspace", "status", "confirm", + ) + return waitCue && verifyCue +} + func formatRunStateReport(rs runState) string { lines := []string{ fmt.Sprintf("Run ID: %s", rs.runID), @@ -2655,6 +2687,20 @@ func (al *AgentLoop) runLLMToolLoop( }) if len(response.ToolCalls) == 0 { + if shouldRetryAfterDeferralNoTools(response.Content, state.iteration, state.deferralRetried, hasToolMessages(messages), systemMode) { + state.deferralRetried = true + messages = append(messages, providers.Message{ + Role: "user", + Content: "Do not ask the user to wait. If verification is needed, call the required tools now and then provide the result in the same run.", + }) + if !systemMode { + logger.WarnCF("agent", "Detected deferral-style direct reply without tool calls; forcing another tool-planning round", map[string]interface{}{ + "iteration": iteration, + "session_key": sessionKey, + }) + } + continue + } state.finalContent = response.Content state.consecutiveAllToolErrorRounds = 0 state.repeatedToolCallRounds = 0 @@ -2840,6 +2886,7 @@ type toolLoopState struct { lastReflectDecision string lastReflectConfidence float64 lastReflectIteration int + deferralRetried bool } type toolActOutcome struct { diff --git a/pkg/agent/loop_toolloop_test.go b/pkg/agent/loop_toolloop_test.go index 6292750..e9ea602 100644 --- a/pkg/agent/loop_toolloop_test.go +++ b/pkg/agent/loop_toolloop_test.go @@ -174,6 +174,32 @@ func (t replayTool) ResourceKeys(args map[string]interface{}) []string { return t.resourceKeys(args) } +type deferralRetryProvider struct { + planCalls int +} + +func (p *deferralRetryProvider) Chat(ctx context.Context, messages []providers.Message, defs []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { + if len(defs) == 0 { + return &providers.LLMResponse{Content: "finalized"}, nil + } + p.planCalls++ + switch p.planCalls { + case 1: + return &providers.LLMResponse{Content: "需要先查看一下当前工作区才能确认,请稍等。"}, nil + case 2: + return &providers.LLMResponse{ + Content: "先检查状态", + ToolCalls: []providers.ToolCall{ + {ID: "tc-status-1", Name: "read_file", Arguments: map[string]interface{}{"path": "README.md"}}, + }, + }, nil + default: + return &providers.LLMResponse{Content: "已完成状态检查,当前一切正常。"}, nil + } +} + +func (p *deferralRetryProvider) GetDefaultModel() string { return "test-model" } + func TestActToolCalls_BudgetTruncationReplay(t *testing.T) { t.Parallel() @@ -600,6 +626,70 @@ func TestShouldForceSelfRepairHeuristic(t *testing.T) { } } +func TestShouldRetryAfterDeferralNoTools(t *testing.T) { + t.Parallel() + + if !shouldRetryAfterDeferralNoTools("需要先查看一下当前工作区才能确认,请稍等。", 1, false, false, false) { + t.Fatalf("expected deferral text to trigger retry") + } + if shouldRetryAfterDeferralNoTools("这里是直接答案。", 1, false, false, false) { + t.Fatalf("did not expect normal direct answer to trigger retry") + } + if shouldRetryAfterDeferralNoTools("需要先查看一下当前工作区才能确认,请稍等。", 2, false, false, false) { + t.Fatalf("did not expect retry after first iteration") + } +} + +func TestRunLLMToolLoop_RecoversFromDeferralWithoutTools(t *testing.T) { + t.Parallel() + + var toolExecCount int32 + reg := tools.NewToolRegistry() + reg.Register(replayToolImpl{ + name: "read_file", + run: func(ctx context.Context, args map[string]interface{}) (string, error) { + atomic.AddInt32(&toolExecCount, 1) + return "README content", nil + }, + }) + + provider := &deferralRetryProvider{} + al := &AgentLoop{ + provider: provider, + providersByProxy: map[string]providers.LLMProvider{"proxy": provider}, + modelsByProxy: map[string][]string{"proxy": []string{"test-model"}}, + proxy: "proxy", + model: "test-model", + maxIterations: 5, + llmCallTimeout: 3 * time.Second, + tools: reg, + sessions: session.NewSessionManager(""), + workspace: t.TempDir(), + } + + msgs := []providers.Message{ + {Role: "system", Content: "test system"}, + {Role: "user", Content: "当前状态"}, + } + + out, iterations, err := al.runLLMToolLoop(context.Background(), msgs, "deferral:test", false, nil) + if err != nil { + t.Fatalf("runLLMToolLoop error: %v", err) + } + if strings.TrimSpace(out) == "" { + t.Fatalf("expected non-empty output") + } + if provider.planCalls < 3 { + t.Fatalf("expected additional planning round after deferral, got planCalls=%d", provider.planCalls) + } + if atomic.LoadInt32(&toolExecCount) == 0 { + t.Fatalf("expected tool execution after deferral recovery") + } + if iterations < 3 { + t.Fatalf("expected at least 3 iterations, got %d", iterations) + } +} + func TestSelfRepairMemoryPromptDedup(t *testing.T) { t.Parallel()