feat(core): 实现 Doom Loop 检测和软性 maxSteps 限制

- 新增 doom-loop.ts: 实现 Doom Loop 检测器,检测连续 3 次相同工具调用
- 修改 agent.ts: 集成检测器,添加 onDoomLoop 回调,动态 maxSteps
- 修改 registry.ts: 默认 maxSteps 从 10 改为 50
- 更新 index.ts: 导出新模块

参考 OpenCode/OpenHands 等开源项目的多层防御策略
This commit is contained in:
2025-12-15 18:14:09 +08:00
parent 3fd8fd98b8
commit 11d4abfc50
4 changed files with 200 additions and 7 deletions
+2 -2
View File
@@ -156,8 +156,8 @@ export class AgentRegistry {
* 应用全局配置到 Agent * 应用全局配置到 Agent
*/ */
private applyGlobalConfig(agent: AgentInfo): AgentInfo { private applyGlobalConfig(agent: AgentInfo): AgentInfo {
// 合并 maxSteps // 合并 maxSteps(默认 50,提供足够的执行空间)
const maxSteps = agent.maxSteps ?? this.globalConfig?.maxSteps ?? 10; const maxSteps = agent.maxSteps ?? this.globalConfig?.maxSteps ?? 50;
// 合并模型配置 // 合并模型配置
const model = { const model = {
+52 -4
View File
@@ -22,6 +22,11 @@ import { loadVisionConfig } from '../utils/config.js';
import { getProviderRegistry, resolveApiKey } from '../provider/index.js'; import { getProviderRegistry, resolveApiKey } from '../provider/index.js';
import { getHookManager } from '../hooks/index.js'; import { getHookManager } from '../hooks/index.js';
import { getGitManager } from '../git/index.js'; import { getGitManager } from '../git/index.js';
import {
createDoomLoopDetector,
type DoomLoopDetector,
DOOM_LOOP_WARNING,
} from './doom-loop.js';
/** /**
* 工具调用开始事件信息 * 工具调用开始事件信息
@@ -43,6 +48,14 @@ export interface ToolEndInfo {
duration?: number; duration?: number;
} }
/**
* Doom Loop 检测事件信息
*/
export interface DoomLoopInfo {
toolName: string;
count: number;
}
/** /**
* Agent.chat() 选项 * Agent.chat() 选项
*/ */
@@ -52,6 +65,8 @@ export interface AgentChatOptions {
onToolStart?: (info: ToolStartInfo) => void; onToolStart?: (info: ToolStartInfo) => void;
/** 工具执行完成回调 */ /** 工具执行完成回调 */
onToolEnd?: (info: ToolEndInfo) => void; onToolEnd?: (info: ToolEndInfo) => void;
/** Doom Loop 检测回调 */
onDoomLoop?: (info: DoomLoopInfo) => void;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
@@ -72,6 +87,9 @@ export class Agent {
// 压缩管理器 // 压缩管理器
private compressionManager: CompressionManager; private compressionManager: CompressionManager;
// Doom Loop 检测器
private doomLoopDetector: DoomLoopDetector = createDoomLoopDetector();
// 当前 Agent 模式(null 表示默认模式) // 当前 Agent 模式(null 表示默认模式)
private currentAgentMode: AgentInfo | null = null; private currentAgentMode: AgentInfo | null = null;
@@ -348,10 +366,15 @@ export class Agent {
async chat(userMessage: string | UserInput, options?: AgentChatOptions | ((text: string) => void)): Promise<ChatResult> { async chat(userMessage: string | UserInput, options?: AgentChatOptions | ((text: string) => void)): Promise<ChatResult> {
// 兼容旧的 onStream 参数 // 兼容旧的 onStream 参数
const opts: AgentChatOptions = typeof options === 'function' ? { onStream: options } : (options || {}); const opts: AgentChatOptions = typeof options === 'function' ? { onStream: options } : (options || {});
const { onStream, onToolStart, onToolEnd, abortSignal } = opts; const { onStream, onToolStart, onToolEnd, onDoomLoop, abortSignal } = opts;
// 重置 doom loop 检测器(每次对话开始时)
this.doomLoopDetector.reset();
// 工具调用时间跟踪 // 工具调用时间跟踪
const toolStartTimes = new Map<string, number>(); const toolStartTimes = new Map<string, number>();
// Doom loop 检测状态
let doomLoopTriggered = false;
// 处理带图片的消息 // 处理带图片的消息
let processedMessage = userMessage; let processedMessage = userMessage;
@@ -422,13 +445,16 @@ export class Agent {
if (onStream) { if (onStream) {
// 流式模式 // 流式模式
// 获取当前 Agent 的 maxSteps 配置(默认 50
const maxSteps = this.currentAgentMode?.maxSteps ?? 50;
const result = streamText({ const result = streamText({
model: this.getModel(this.config.model), model: this.getModel(this.config.model),
system: this.config.systemPrompt, system: this.config.systemPrompt,
messages: this.conversationHistory, messages: this.conversationHistory,
tools: vercelTools, tools: doomLoopTriggered ? {} : vercelTools, // doom loop 时禁用工具
maxOutputTokens: this.config.maxTokens, maxOutputTokens: this.config.maxTokens,
stopWhen: stepCountIs(10), // 允许最多 10 轮工具调用 stopWhen: stepCountIs(maxSteps),
abortSignal, // 支持取消 abortSignal, // 支持取消
onChunk: ({ chunk }) => { onChunk: ({ chunk }) => {
if (chunk.type === 'tool-call') { if (chunk.type === 'tool-call') {
@@ -436,6 +462,25 @@ export class Agent {
const toolCallChunk = chunk as { toolCallId: string; toolName: string; input: unknown }; const toolCallChunk = chunk as { toolCallId: string; toolName: string; input: unknown };
const toolCallId = toolCallChunk.toolCallId || `tool-${Date.now()}`; const toolCallId = toolCallChunk.toolCallId || `tool-${Date.now()}`;
// Doom Loop 检测:记录工具调用
this.doomLoopDetector.record(
toolCallChunk.toolName,
toolCallChunk.input
);
// 检查是否触发 doom loop
if (this.doomLoopDetector.isTriggered() && !doomLoopTriggered) {
doomLoopTriggered = true;
const toolName = this.doomLoopDetector.getLastToolName() || toolCallChunk.toolName;
// 通知回调
onDoomLoop?.({ toolName, count: 3 });
// 输出警告
onStream?.(`\n[警告: 检测到 Doom Loop - ${toolName} 被重复调用]\n`);
onStream?.(DOOM_LOOP_WARNING);
}
// 记录开始时间 // 记录开始时间
toolStartTimes.set(toolCallId, Date.now()); toolStartTimes.set(toolCallId, Date.now());
@@ -534,13 +579,16 @@ export class Agent {
} }
} else { } else {
// 非流式模式 // 非流式模式
// 获取当前 Agent 的 maxSteps 配置(默认 50
const maxSteps = this.currentAgentMode?.maxSteps ?? 50;
const result = await generateText({ const result = await generateText({
model: this.getModel(this.config.model), model: this.getModel(this.config.model),
system: this.config.systemPrompt, system: this.config.systemPrompt,
messages: this.conversationHistory, messages: this.conversationHistory,
tools: vercelTools, tools: vercelTools,
maxOutputTokens: this.config.maxTokens, maxOutputTokens: this.config.maxTokens,
stopWhen: stepCountIs(10), // 允许最多 10 轮工具调用 stopWhen: stepCountIs(maxSteps),
abortSignal, // 支持取消 abortSignal, // 支持取消
}); });
+136
View File
@@ -0,0 +1,136 @@
/**
* Doom Loop 检测器
*
* 检测模型是否陷入重复工具调用的死循环。
* 参考 OpenCode 和 OpenHands 的实现。
*/
import { createHash } from 'crypto';
/**
* 工具调用记录
*/
export interface ToolCallRecord {
toolName: string;
inputHash: string;
}
/**
* Doom Loop 检测器接口
*/
export interface DoomLoopDetector {
/** 记录工具调用 */
record(toolName: string, input: unknown): void;
/** 检测是否触发 doom loop */
isTriggered(): boolean;
/** 重置记录(新对话时调用) */
reset(): void;
/** 获取当前状态描述(用于日志) */
getStatus(): string;
/** 获取最后重复的工具名称(触发时使用) */
getLastToolName(): string | null;
}
/**
* Doom Loop 检测阈值
* 连续 N 次相同的工具调用(相同工具名 + 相同参数)触发检测
*/
export const DOOM_LOOP_THRESHOLD = 3;
/**
* 计算输入参数的哈希值
*/
function hashInput(input: unknown): string {
const str = JSON.stringify(input, Object.keys(input as object).sort());
return createHash('md5').update(str).digest('hex').slice(0, 16);
}
/**
* 创建 Doom Loop 检测器
*/
export function createDoomLoopDetector(threshold = DOOM_LOOP_THRESHOLD): DoomLoopDetector {
const history: ToolCallRecord[] = [];
let triggered = false;
let lastToolName: string | null = null;
return {
record(toolName: string, input: unknown): void {
const inputHash = hashInput(input);
history.push({ toolName, inputHash });
// 只保留最近 threshold 条记录
if (history.length > threshold) {
history.shift();
}
// 检测是否触发
if (history.length >= threshold) {
const lastN = history.slice(-threshold);
const first = lastN[0];
const allSame = lastN.every(
(call) => call.toolName === first.toolName && call.inputHash === first.inputHash
);
if (allSame) {
triggered = true;
lastToolName = first.toolName;
}
}
},
isTriggered(): boolean {
return triggered;
},
reset(): void {
history.length = 0;
triggered = false;
lastToolName = null;
},
getStatus(): string {
if (triggered) {
return `Doom loop detected: ${lastToolName} called ${threshold} times with same input`;
}
return `Normal: ${history.length} calls recorded`;
},
getLastToolName(): string | null {
return lastToolName;
},
};
}
/**
* MAX_STEPS 达到时的警告提示
* 注入到 system prompt 中,让模型输出总结
*/
export const MAX_STEPS_WARNING = `
CRITICAL - MAXIMUM STEPS REACHED
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
STRICT REQUIREMENTS:
1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools)
2. MUST provide a text response summarizing work done so far
3. This constraint overrides ALL other instructions
Please summarize:
1. What has been completed
2. What remains to be done
3. Any recommendations for next steps
`;
/**
* Doom Loop 触发时的警告提示
*/
export const DOOM_LOOP_WARNING = `
WARNING - DOOM LOOP DETECTED
The same tool call has been repeated multiple times with identical inputs, indicating a potential infinite loop. Tools are temporarily disabled.
Please:
1. Explain what you were trying to accomplish
2. Describe why the repeated calls might not be working
3. Suggest an alternative approach
`;
+10 -1
View File
@@ -1,5 +1,14 @@
export { Agent } from './core/agent.js'; export { Agent } from './core/agent.js';
export type { AgentChatOptions, ToolStartInfo, ToolEndInfo } from './core/agent.js'; export type { AgentChatOptions, ToolStartInfo, ToolEndInfo, DoomLoopInfo } from './core/agent.js';
// Doom Loop Detection
export {
createDoomLoopDetector,
DOOM_LOOP_THRESHOLD,
DOOM_LOOP_WARNING,
MAX_STEPS_WARNING,
} from './core/doom-loop.js';
export type { DoomLoopDetector } from './core/doom-loop.js';
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js'; export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
export { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js'; export { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js';
export type { VisionConfig } from './utils/config.js'; export type { VisionConfig } from './utils/config.js';