feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1
This commit is contained in:
@@ -65,6 +65,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
private currentSessionId: string = '';
|
||||
private currentRequestId: string = '';
|
||||
private workspacePath: string = '';
|
||||
private abortController: AbortController | null = null;
|
||||
private eventSeq = 0;
|
||||
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
|
||||
private runId: string = '';
|
||||
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
|
||||
private currentRunPromise: Promise<AgentLoopOutput> | null = null;
|
||||
|
||||
constructor(
|
||||
config: Partial<AgentLoopConfig> = {},
|
||||
@@ -127,13 +133,34 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
sessionId: string,
|
||||
history: MetonaMessage[],
|
||||
systemPrompt: MetonaSystemPrompt,
|
||||
): Promise<AgentLoopOutput> {
|
||||
// C-4/H-6: 等待上一次 run 完全结束,防止并发 run 污染状态和旧 DONE 中断新流
|
||||
if (this.currentRunPromise) {
|
||||
await this.currentRunPromise.catch(() => {});
|
||||
}
|
||||
this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt);
|
||||
try {
|
||||
return await this.currentRunPromise;
|
||||
} finally {
|
||||
this.currentRunPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRunStream(
|
||||
userMessage: MetonaMessage,
|
||||
sessionId: string,
|
||||
history: MetonaMessage[],
|
||||
systemPrompt: MetonaSystemPrompt,
|
||||
): Promise<AgentLoopOutput> {
|
||||
this.startTime = Date.now();
|
||||
this.aborted = false;
|
||||
this.runId = `run_${nanoid(8)}`;
|
||||
this.iterations = [];
|
||||
this.currentIteration = 0;
|
||||
this.currentSessionId = sessionId;
|
||||
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||
this.abortController = new AbortController();
|
||||
this.eventSeq = 0;
|
||||
|
||||
try {
|
||||
await this.transitionTo(AgentLoopState.INIT);
|
||||
@@ -220,7 +247,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return this.finish(TerminationReason.MAX_ITERATIONS);
|
||||
return this.finish(TerminationReason.TIMEOUT);
|
||||
} catch (error) {
|
||||
this.emit('error', { error: (error as Error).message });
|
||||
const errMsg = (error as Error).message;
|
||||
if (this.aborted || errMsg.includes('Aborted')) {
|
||||
return this.finish(TerminationReason.USER_INTERRUPT);
|
||||
}
|
||||
this.emit('error', { error: errMsg });
|
||||
return this.finish(TerminationReason.ERROR, undefined, error as Error);
|
||||
}
|
||||
}
|
||||
@@ -238,9 +269,19 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
/** 中断循环 */
|
||||
abort(): void {
|
||||
this.aborted = true;
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
this.emit('aborted');
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁引擎,清理所有监听器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.abort();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/** 获取当前状态 */
|
||||
getState(): AgentLoopState {
|
||||
return this.currentState;
|
||||
@@ -279,8 +320,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
||||
if (event.type === MetonaStreamEventType.DONE) continue;
|
||||
|
||||
// 转发流式事件到渲染进程
|
||||
this.emit('streamEvent', event);
|
||||
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||||
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
|
||||
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') {
|
||||
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
|
||||
fullContent = '';
|
||||
reasoningContent = '';
|
||||
toolCallsBuffer.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 转发流式事件到渲染进程(注入 runId 供前端过滤旧流)
|
||||
this.emit('streamEvent', { ...event, runId: this.runId });
|
||||
|
||||
switch (event.type) {
|
||||
case MetonaStreamEventType.TEXT_DELTA:
|
||||
@@ -322,6 +373,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
break;
|
||||
|
||||
case MetonaStreamEventType.ERROR:
|
||||
// RETRY 已在循环入口过滤,此处只处理真正的错误
|
||||
if (event.error) {
|
||||
throw new Error(event.error.message);
|
||||
}
|
||||
@@ -329,6 +381,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
|
||||
await this.transitionTo(AgentLoopState.PARSING);
|
||||
|
||||
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
@@ -376,29 +431,89 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||
const result = await this.executeToolSafely(tc);
|
||||
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
|
||||
if (!this.aborted) {
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
|
||||
const toolsPromise = Promise.allSettled(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
const controller = this.abortController;
|
||||
const abortPromise = new Promise<null>((resolve) => {
|
||||
if (this.aborted) return resolve(null);
|
||||
if (controller) {
|
||||
controller.signal.addEventListener('abort', () => resolve(null), { once: true });
|
||||
}
|
||||
});
|
||||
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
|
||||
| PromiseSettledResult<MetonaToolResult>[]
|
||||
| null;
|
||||
|
||||
if (raceResult === null) {
|
||||
// 被 abort 中断,标记步骤并退出
|
||||
step.completedAt = Date.now();
|
||||
step.state = AgentLoopState.TERMINATED;
|
||||
return step;
|
||||
}
|
||||
|
||||
const settledResults = raceResult;
|
||||
step.toolResults = settledResults.map((result, idx) => {
|
||||
const tc = step.toolCalls![idx];
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
// rejected:构造失败 result
|
||||
const errorResult = {
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: `Tool execution failed: ${(result.reason as Error)?.message ?? String(result.reason)}`,
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 转发错误结果到 UI
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: 0,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
toolResult: errorResult,
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Promise.all 保持结果顺序与 toolCalls 一致
|
||||
step.toolResults = await Promise.all(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
return errorResult;
|
||||
});
|
||||
}
|
||||
|
||||
// === OBSERVING ===
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
|
||||
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
|
||||
// 如果有工具调用且需要后续推理,进入 REFLECTING 状态
|
||||
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
|
||||
await this.transitionTo(AgentLoopState.REFLECTING);
|
||||
// 检查工具执行是否有错误,如果有严重错误可以提前终止
|
||||
const hasErrors = step.toolResults?.some((r) => !r.success);
|
||||
if (hasErrors) {
|
||||
log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
|
||||
@@ -416,6 +531,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||
});
|
||||
}
|
||||
// 压缩后回到 OBSERVING
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
}
|
||||
|
||||
step.completedAt = Date.now();
|
||||
@@ -455,13 +572,37 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
const toolResult = await this.toolRegistry.execute(toolCall, {
|
||||
sessionId: this.currentSessionId ?? '',
|
||||
workspacePath: this.workspacePath,
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
});
|
||||
// 执行工具(带超时)
|
||||
const toolTimeout = 120_000; // 单个工具最大 2 分钟
|
||||
let toolResult: MetonaToolResult;
|
||||
try {
|
||||
toolResult = await Promise.race([
|
||||
this.toolRegistry.execute(toolCall, {
|
||||
sessionId: this.currentSessionId ?? '',
|
||||
workspacePath: this.workspacePath,
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
}),
|
||||
new Promise<MetonaToolResult>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout),
|
||||
),
|
||||
]);
|
||||
} catch (err) {
|
||||
toolResult = {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 仍执行 post-hook
|
||||
for (const hook of this.postToolHooks) {
|
||||
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
|
||||
}
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
// 后置 Hook 管道
|
||||
for (const hook of this.postToolHooks) {
|
||||
@@ -472,26 +613,94 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的流式调用
|
||||
* 带重试的流式调用(v0.2.0: 指数退避)
|
||||
*
|
||||
* 如果 adapter 抛出错误,在 retryCount 次数内重试,每次等待 1 秒。
|
||||
* 如果 adapter 抛出错误,在 retryCount 次数内重试。
|
||||
* v0.2.0: 使用指数退避替代固定 1 秒等待
|
||||
* 等待时间 = baseDelay * 2^attempt(1s, 2s, 4s, 8s...)
|
||||
* 上限 30 秒,加上 ±20% 随机抖动(jitter)避免惊群效应
|
||||
*/
|
||||
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
let lastError: unknown;
|
||||
const baseDelayMs = 1_000;
|
||||
const maxDelayMs = 30_000;
|
||||
|
||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
// 首次尝试直接 yield
|
||||
if (attempt === 0) {
|
||||
yield* this.adapter.chatStream(request);
|
||||
return;
|
||||
}
|
||||
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
|
||||
yield {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
iteration: request.meta.iteration,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
error: {
|
||||
code: 'RETRY' as never,
|
||||
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
|
||||
retryable: true,
|
||||
},
|
||||
} as MetonaStreamEvent;
|
||||
yield* this.adapter.chatStream(request);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (this.aborted) throw error;
|
||||
if (attempt < this.config.retryCount) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// 检查是否为可重试错误
|
||||
if (!this.isRetryableError(error)) throw error;
|
||||
// 指数退避 + 抖动
|
||||
const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
|
||||
const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter
|
||||
const waitMs = Math.max(500, delay + jitter);
|
||||
log.warn(`[AgentLoop] Retry ${attempt + 1}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, waitMs);
|
||||
// 支持 abort 中断等待
|
||||
const signal = this.abortController?.signal;
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Aborted'));
|
||||
}, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** 判断错误是否可重试 */
|
||||
private isRetryableError(error: unknown): boolean {
|
||||
const err = error as { status?: number; code?: string; message?: string };
|
||||
// 429 Too Many Requests — 可重试
|
||||
if (err.status === 429) return true;
|
||||
// 5xx 服务器错误 — 可重试
|
||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||
// 网络超时/连接错误 — 可重试
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
||||
// SSE 流中断 — 可重试
|
||||
if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true;
|
||||
// 其他错误(400/401/403/4xx)不重试
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 生成下一个事件序列号 */
|
||||
private nextSeq(): number {
|
||||
return ++this.eventSeq;
|
||||
}
|
||||
|
||||
private async transitionTo(state: AgentLoopState): Promise<void> {
|
||||
const previous = this.currentState;
|
||||
this.currentState = state;
|
||||
@@ -501,6 +710,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
state,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -610,11 +820,22 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
requestId: this.currentRequestId,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: 0,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||||
const prevTerminated = this.currentState;
|
||||
this.currentState = AgentLoopState.TERMINATED;
|
||||
this.emit('stateChange', {
|
||||
previous: prevTerminated,
|
||||
current: AgentLoopState.TERMINATED,
|
||||
state: AgentLoopState.TERMINATED,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
// 发射 complete 事件(供托盘通知等外部监听器使用)
|
||||
this.emit('complete', {
|
||||
|
||||
Reference in New Issue
Block a user