v0.1.2: 全项目代码审查修复 + 子代理编排器 + 上下文压缩 + 并行工具执行
后端修复: Ollama adapter 移除死代码/修复超时硬编码/iteration硬编码/pullModel无超时/流异常断开补发DONE; SSE解析器支持非字符串arguments; Sandbox fail-closed安全加固; IPC移除未使用变量 新增功能: TaskOrchestrator子任务编排器; DelegateTaskTool委派工具; Agent Loop并行工具执行; 上下文自动压缩; 记忆检索注入System Prompt 前端修复: useAgentStream text_delta thought累积bug; 工具调用状态正确流转; 修复重复stateChange事件; TraceStep.thought正确填充
This commit is contained in:
@@ -34,6 +34,7 @@ import type {
|
||||
MetonaToolDef,
|
||||
} from '../types';
|
||||
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
|
||||
import log from 'electron-log';
|
||||
|
||||
const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||
maxIterations: 20,
|
||||
@@ -224,6 +225,16 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前 Provider Adapter(供 SubAgent 创建独立引擎实例) */
|
||||
getAdapter(): IMetonaProviderAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
|
||||
/** 获取工作空间路径(供 SubAgent 继承) */
|
||||
getWorkspacePath(): string {
|
||||
return this.workspacePath;
|
||||
}
|
||||
|
||||
/** 中断循环 */
|
||||
abort(): void {
|
||||
this.aborted = true;
|
||||
@@ -253,12 +264,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
try {
|
||||
// === THINKING: 流式调用 LLM ===
|
||||
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||||
await this.transitionTo(AgentLoopState.THINKING);
|
||||
this.emit('stateChange', {
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
state: 'THINKING',
|
||||
});
|
||||
|
||||
let fullContent = '';
|
||||
let reasoningContent = '';
|
||||
@@ -362,19 +369,14 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// === EXECUTING: 执行工具调用 ===
|
||||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||||
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||||
await this.transitionTo(AgentLoopState.EXECUTING);
|
||||
this.emit('stateChange', {
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
state: 'EXECUTING',
|
||||
});
|
||||
|
||||
step.toolResults = [];
|
||||
for (const tc of step.toolCalls) {
|
||||
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发)
|
||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||
const result = await this.executeToolSafely(tc);
|
||||
step.toolResults.push(result);
|
||||
|
||||
// 转发工具结果到渲染进程
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
@@ -384,25 +386,33 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Promise.all 保持结果顺序与 toolCalls 一致
|
||||
step.toolResults = await Promise.all(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
}
|
||||
|
||||
// === OBSERVING ===
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
|
||||
// === 上下文压缩(每 5 轮检查一次) ===
|
||||
if (this.currentIteration % 5 === 0) {
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
const estimatedTokens = this.estimateMessagesTokens(request.messages);
|
||||
const compressionThreshold = this.config.compressionThreshold * this.config.contextWindow;
|
||||
if (estimatedTokens > compressionThreshold && request.messages.length > 10) {
|
||||
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||
this.emit('stateChange', {
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
state: 'COMPRESSING',
|
||||
});
|
||||
|
||||
// 在 context 中标记压缩点
|
||||
const keepRecent = 3;
|
||||
if (this.iterations.length > keepRecent) {
|
||||
this.emit('compressed', { iteration: this.currentIteration, keptRounds: keepRecent });
|
||||
const compressed = await this.compressMessages(request.messages);
|
||||
if (compressed) {
|
||||
// 原地替换数组内容,确保外层 messages 引用同步更新
|
||||
request.messages.splice(0, request.messages.length, ...compressed);
|
||||
this.emit('compressed', {
|
||||
iteration: this.currentIteration,
|
||||
originalTokens: estimatedTokens,
|
||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +502,95 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算消息列表的 token 数(粗略:1 字符 ≈ 0.5 token)
|
||||
*/
|
||||
private estimateMessagesTokens(messages: MetonaMessage[]): number {
|
||||
let total = 0;
|
||||
for (const msg of messages) {
|
||||
total += Math.ceil(msg.content.length / 2);
|
||||
if (msg.reasoningContent) total += Math.ceil(msg.reasoningContent.length / 2);
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
total += Math.ceil(JSON.stringify(tc.args).length / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
|
||||
*
|
||||
* 策略:
|
||||
* 1. 保留最后 keepRecent 条消息(约 3 轮对话)
|
||||
* 2. 将前面的所有消息交给 LLM 生成摘要
|
||||
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组
|
||||
*
|
||||
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
|
||||
*/
|
||||
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
|
||||
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool)
|
||||
if (messages.length <= keepRecent) return null;
|
||||
|
||||
const toCompress = messages.slice(0, messages.length - keepRecent);
|
||||
const toKeep = messages.slice(messages.length - keepRecent);
|
||||
|
||||
// 构建摘要请求
|
||||
const conversationText = toCompress.map((m) => {
|
||||
const role = m.role.toUpperCase();
|
||||
const content = m.content.slice(0, 500); // 每条消息最多 500 字符
|
||||
return `[${role}] ${content}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const summaryRequest: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
requestId: `r_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a conversation summarizer.',
|
||||
outputConstraints: 'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
|
||||
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Please summarize the following conversation history:\n\n${conversationText}`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.chat(summaryRequest);
|
||||
const summary = response.content.trim();
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const summaryMessage: MetonaMessage = {
|
||||
role: 'system',
|
||||
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
|
||||
|
||||
return [summaryMessage, ...toKeep];
|
||||
} catch (error) {
|
||||
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private accumulateTokens(usage: TokenUsage): void {
|
||||
this.totalTokens.promptTokens += usage.promptTokens;
|
||||
this.totalTokens.completionTokens += usage.completionTokens;
|
||||
@@ -514,6 +613,16 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
});
|
||||
|
||||
this.currentState = AgentLoopState.TERMINATED;
|
||||
|
||||
// 发射 complete 事件(供托盘通知等外部监听器使用)
|
||||
this.emit('complete', {
|
||||
sessionId: this.currentSessionId,
|
||||
durationMs: Date.now() - this.startTime,
|
||||
terminationReason: reason,
|
||||
iterations: this.iterations.length,
|
||||
totalTokens: this.totalTokens.totalTokens,
|
||||
});
|
||||
|
||||
return {
|
||||
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
|
||||
terminationReason: reason,
|
||||
|
||||
Reference in New Issue
Block a user