This commit is contained in:
lpf
2026-02-19 15:50:25 +08:00
parent 15bd337c49
commit 67dc3cab4d
2 changed files with 137 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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()