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:
@@ -156,8 +156,8 @@ export class AgentRegistry {
|
||||
* 应用全局配置到 Agent
|
||||
*/
|
||||
private applyGlobalConfig(agent: AgentInfo): AgentInfo {
|
||||
// 合并 maxSteps
|
||||
const maxSteps = agent.maxSteps ?? this.globalConfig?.maxSteps ?? 10;
|
||||
// 合并 maxSteps(默认 50,提供足够的执行空间)
|
||||
const maxSteps = agent.maxSteps ?? this.globalConfig?.maxSteps ?? 50;
|
||||
|
||||
// 合并模型配置
|
||||
const model = {
|
||||
|
||||
@@ -22,6 +22,11 @@ import { loadVisionConfig } from '../utils/config.js';
|
||||
import { getProviderRegistry, resolveApiKey } from '../provider/index.js';
|
||||
import { getHookManager } from '../hooks/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Doom Loop 检测事件信息
|
||||
*/
|
||||
export interface DoomLoopInfo {
|
||||
toolName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent.chat() 选项
|
||||
*/
|
||||
@@ -52,6 +65,8 @@ export interface AgentChatOptions {
|
||||
onToolStart?: (info: ToolStartInfo) => void;
|
||||
/** 工具执行完成回调 */
|
||||
onToolEnd?: (info: ToolEndInfo) => void;
|
||||
/** Doom Loop 检测回调 */
|
||||
onDoomLoop?: (info: DoomLoopInfo) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -72,6 +87,9 @@ export class Agent {
|
||||
// 压缩管理器
|
||||
private compressionManager: CompressionManager;
|
||||
|
||||
// Doom Loop 检测器
|
||||
private doomLoopDetector: DoomLoopDetector = createDoomLoopDetector();
|
||||
|
||||
// 当前 Agent 模式(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> {
|
||||
// 兼容旧的 onStream 参数
|
||||
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>();
|
||||
// Doom loop 检测状态
|
||||
let doomLoopTriggered = false;
|
||||
// 处理带图片的消息
|
||||
let processedMessage = userMessage;
|
||||
|
||||
@@ -422,13 +445,16 @@ export class Agent {
|
||||
|
||||
if (onStream) {
|
||||
// 流式模式
|
||||
// 获取当前 Agent 的 maxSteps 配置(默认 50)
|
||||
const maxSteps = this.currentAgentMode?.maxSteps ?? 50;
|
||||
|
||||
const result = streamText({
|
||||
model: this.getModel(this.config.model),
|
||||
system: this.config.systemPrompt,
|
||||
messages: this.conversationHistory,
|
||||
tools: vercelTools,
|
||||
tools: doomLoopTriggered ? {} : vercelTools, // doom loop 时禁用工具
|
||||
maxOutputTokens: this.config.maxTokens,
|
||||
stopWhen: stepCountIs(10), // 允许最多 10 轮工具调用
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
abortSignal, // 支持取消
|
||||
onChunk: ({ chunk }) => {
|
||||
if (chunk.type === 'tool-call') {
|
||||
@@ -436,6 +462,25 @@ export class Agent {
|
||||
const toolCallChunk = chunk as { toolCallId: string; toolName: string; input: unknown };
|
||||
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());
|
||||
|
||||
@@ -534,13 +579,16 @@ export class Agent {
|
||||
}
|
||||
} else {
|
||||
// 非流式模式
|
||||
// 获取当前 Agent 的 maxSteps 配置(默认 50)
|
||||
const maxSteps = this.currentAgentMode?.maxSteps ?? 50;
|
||||
|
||||
const result = await generateText({
|
||||
model: this.getModel(this.config.model),
|
||||
system: this.config.systemPrompt,
|
||||
messages: this.conversationHistory,
|
||||
tools: vercelTools,
|
||||
maxOutputTokens: this.config.maxTokens,
|
||||
stopWhen: stepCountIs(10), // 允许最多 10 轮工具调用
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
abortSignal, // 支持取消
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
@@ -1,5 +1,14 @@
|
||||
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 { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js';
|
||||
export type { VisionConfig } from './utils/config.js';
|
||||
|
||||
Reference in New Issue
Block a user