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:
@@ -172,7 +172,7 @@ export class DeepSeekAdapter extends BaseAdapter {
|
|||||||
} else if (request.params.thinkingEnabled) {
|
} else if (request.params.thinkingEnabled) {
|
||||||
body.thinking = { type: 'enabled' };
|
body.thinking = { type: 'enabled' };
|
||||||
const effortMap: Record<string, string> = {
|
const effortMap: Record<string, string> = {
|
||||||
low: 'high', medium: 'high', high: 'high', xhigh: 'max', max: 'max',
|
low: 'high', medium: 'high', high: 'high', max: 'max',
|
||||||
};
|
};
|
||||||
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high
|
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high
|
||||||
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
|
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as Record<string, unknown>;
|
const data = await response.json() as Record<string, unknown>;
|
||||||
return this.toMetonaResponse(data, request.meta.requestId);
|
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
|
||||||
}
|
}
|
||||||
|
|
||||||
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||||
@@ -66,7 +66,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
body: JSON.stringify({ ...nativeRequest, stream: true }),
|
||||||
signal: AbortSignal.timeout(300_000),
|
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
@@ -77,9 +77,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
let streamEndedNormally = false;
|
||||||
// 工具调用缓冲区
|
|
||||||
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -147,6 +145,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
|
|
||||||
// 流结束
|
// 流结束
|
||||||
if (chunk.done) {
|
if (chunk.done) {
|
||||||
|
streamEndedNormally = true;
|
||||||
// 发送 usage 信息
|
// 发送 usage 信息
|
||||||
yield {
|
yield {
|
||||||
type: MetonaStreamEventType.USAGE,
|
type: MetonaStreamEventType.USAGE,
|
||||||
@@ -177,6 +176,18 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流未正常结束(连接断开等),补发 DONE 事件防止 Agent Loop 挂起
|
||||||
|
if (!streamEndedNormally) {
|
||||||
|
yield {
|
||||||
|
type: MetonaStreamEventType.DONE,
|
||||||
|
requestId: request.meta.requestId,
|
||||||
|
sessionId: request.meta.sessionId,
|
||||||
|
iteration: request.meta.iteration,
|
||||||
|
seq: seq++,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== POST /api/generate =====
|
// ===== POST /api/generate =====
|
||||||
@@ -281,6 +292,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ model, stream: true }),
|
body: JSON.stringify({ model, stream: true }),
|
||||||
|
signal: AbortSignal.timeout(600_000), // 模型下载可能较慢,10 分钟超时
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`);
|
if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`);
|
||||||
@@ -394,9 +406,9 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
options: {
|
options: {
|
||||||
temperature: request.params.temperature,
|
temperature: request.params.temperature,
|
||||||
num_predict: request.params.maxTokens,
|
num_predict: request.params.maxTokens,
|
||||||
top_p: request.params.topP,
|
...(request.params.topP != null && { top_p: request.params.topP }),
|
||||||
stop: request.params.stopSequences,
|
...(request.params.stopSequences?.length && { stop: request.params.stopSequences }),
|
||||||
num_ctx: request.params.contextLength,
|
...(request.params.contextLength != null && { num_ctx: request.params.contextLength }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -421,7 +433,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
|
private toMetonaResponse(data: Record<string, unknown>, requestId: string, iteration: number = 0): MetonaResponse {
|
||||||
const message = data.message as Record<string, unknown> | undefined;
|
const message = data.message as Record<string, unknown> | undefined;
|
||||||
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
|
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
|
||||||
return {
|
return {
|
||||||
@@ -455,7 +467,7 @@ export class OllamaAdapter extends BaseAdapter {
|
|||||||
id: `tc_${Date.now()}_${i}`,
|
id: `tc_${Date.now()}_${i}`,
|
||||||
name: (fn?.name as string) ?? '',
|
name: (fn?.name as string) ?? '',
|
||||||
args,
|
args,
|
||||||
iteration: 0,
|
iteration,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -223,11 +223,16 @@ export function parseOpenAICompatibleResponse(
|
|||||||
toolCalls: rawToolCalls?.map((tc) => {
|
toolCalls: rawToolCalls?.map((tc) => {
|
||||||
const fn = tc.function as Record<string, unknown>;
|
const fn = tc.function as Record<string, unknown>;
|
||||||
let args: Record<string, unknown> = {};
|
let args: Record<string, unknown> = {};
|
||||||
|
const rawArgs = fn?.arguments;
|
||||||
|
if (typeof rawArgs === 'string') {
|
||||||
try {
|
try {
|
||||||
args = JSON.parse(fn.arguments as string);
|
args = JSON.parse(rawArgs);
|
||||||
} catch {
|
} catch {
|
||||||
args = {};
|
args = {};
|
||||||
}
|
}
|
||||||
|
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||||
|
args = rawArgs as Record<string, unknown>;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: tc.id as string,
|
id: tc.id as string,
|
||||||
name: fn.name as string,
|
name: fn.name as string,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
MetonaToolDef,
|
MetonaToolDef,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
|
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
|
||||||
|
import log from 'electron-log';
|
||||||
|
|
||||||
const DEFAULT_CONFIG: AgentLoopConfig = {
|
const DEFAULT_CONFIG: AgentLoopConfig = {
|
||||||
maxIterations: 20,
|
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 {
|
abort(): void {
|
||||||
this.aborted = true;
|
this.aborted = true;
|
||||||
@@ -253,12 +264,8 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// === THINKING: 流式调用 LLM ===
|
// === THINKING: 流式调用 LLM ===
|
||||||
|
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||||||
await this.transitionTo(AgentLoopState.THINKING);
|
await this.transitionTo(AgentLoopState.THINKING);
|
||||||
this.emit('stateChange', {
|
|
||||||
sessionId,
|
|
||||||
iteration: this.currentIteration,
|
|
||||||
state: 'THINKING',
|
|
||||||
});
|
|
||||||
|
|
||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
let reasoningContent = '';
|
let reasoningContent = '';
|
||||||
@@ -362,19 +369,14 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
|
|
||||||
// === EXECUTING: 执行工具调用 ===
|
// === EXECUTING: 执行工具调用 ===
|
||||||
if (step.toolCalls && step.toolCalls.length > 0) {
|
if (step.toolCalls && step.toolCalls.length > 0) {
|
||||||
|
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||||||
await this.transitionTo(AgentLoopState.EXECUTING);
|
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);
|
const result = await this.executeToolSafely(tc);
|
||||||
step.toolResults.push(result);
|
|
||||||
|
|
||||||
// 转发工具结果到渲染进程
|
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||||
this.emit('streamEvent', {
|
this.emit('streamEvent', {
|
||||||
type: MetonaStreamEventType.TOOL_RESULT,
|
type: MetonaStreamEventType.TOOL_RESULT,
|
||||||
requestId: request.meta.requestId,
|
requestId: request.meta.requestId,
|
||||||
@@ -384,25 +386,33 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
toolResult: result,
|
toolResult: result,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Promise.all 保持结果顺序与 toolCalls 一致
|
||||||
|
step.toolResults = await Promise.all(
|
||||||
|
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === OBSERVING ===
|
// === OBSERVING ===
|
||||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||||
|
|
||||||
// === 上下文压缩(每 5 轮检查一次) ===
|
// === 上下文压缩(基于 token 使用率触发) ===
|
||||||
if (this.currentIteration % 5 === 0) {
|
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);
|
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||||||
this.emit('stateChange', {
|
const compressed = await this.compressMessages(request.messages);
|
||||||
sessionId,
|
if (compressed) {
|
||||||
|
// 原地替换数组内容,确保外层 messages 引用同步更新
|
||||||
|
request.messages.splice(0, request.messages.length, ...compressed);
|
||||||
|
this.emit('compressed', {
|
||||||
iteration: this.currentIteration,
|
iteration: this.currentIteration,
|
||||||
state: 'COMPRESSING',
|
originalTokens: estimatedTokens,
|
||||||
|
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在 context 中标记压缩点
|
|
||||||
const keepRecent = 3;
|
|
||||||
if (this.iterations.length > keepRecent) {
|
|
||||||
this.emit('compressed', { iteration: this.currentIteration, keptRounds: keepRecent });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
private accumulateTokens(usage: TokenUsage): void {
|
||||||
this.totalTokens.promptTokens += usage.promptTokens;
|
this.totalTokens.promptTokens += usage.promptTokens;
|
||||||
this.totalTokens.completionTokens += usage.completionTokens;
|
this.totalTokens.completionTokens += usage.completionTokens;
|
||||||
@@ -514,6 +613,16 @@ export class AgentLoopEngine extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.currentState = AgentLoopState.TERMINATED;
|
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 {
|
return {
|
||||||
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
|
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
|
||||||
terminationReason: reason,
|
terminationReason: reason,
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export class AuditLogHook implements PostToolHook {
|
|||||||
|
|
||||||
/** 记忆触发钩子 */
|
/** 记忆触发钩子 */
|
||||||
export class MemoryTriggerHook implements PostToolHook {
|
export class MemoryTriggerHook implements PostToolHook {
|
||||||
private memorableTools = ['web_search', 'read_file', 'memory_search'];
|
/** 仅对搜索类工具触发记忆,避免每次 read_file 都产生低价值记忆条目 */
|
||||||
|
private memorableTools = ['web_search', 'memory_search'];
|
||||||
|
|
||||||
|
/** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */
|
||||||
|
private readonly MAX_MEMORY_CONTENT = 500;
|
||||||
|
|
||||||
constructor(private memoryManager: MemoryManager) {}
|
constructor(private memoryManager: MemoryManager) {}
|
||||||
|
|
||||||
@@ -44,7 +48,7 @@ export class MemoryTriggerHook implements PostToolHook {
|
|||||||
try {
|
try {
|
||||||
this.memoryManager.store({
|
this.memoryManager.store({
|
||||||
type: 'episodic',
|
type: 'episodic',
|
||||||
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
|
content: `Tool ${toolCall.name} returned: ${content.slice(0, this.MAX_MEMORY_CONTENT)}`,
|
||||||
source: 'tool_result',
|
source: 'tool_result',
|
||||||
sessionId,
|
sessionId,
|
||||||
importance: 0.6,
|
importance: 0.6,
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
* Task Orchestrator — 任务编排器
|
* Task Orchestrator — 任务编排器
|
||||||
*
|
*
|
||||||
* 支持父子委派模式:主 Agent 委派子任务给 SubAgent。
|
* 支持父子委派模式:主 Agent 委派子任务给 SubAgent。
|
||||||
* 每个子任务运行一个独立的 AgentLoopEngine 实例。
|
* 每个 SubAgent 运行在**独立的 AgentLoopEngine 实例**中,避免状态污染。
|
||||||
|
*
|
||||||
|
* 安全保障:
|
||||||
|
* 1. 独立引擎实例 — SubAgent 不共享主 Agent 的引擎状态
|
||||||
|
* 2. 递归深度限制 — 默认最大 3 层,防止无限递归
|
||||||
|
* 3. 工具白名单隔离 — SubAgent 默认不继承 delegate_task(防止递归)
|
||||||
|
* 4. 真正的 abort — 通过引擎引用调用 engine.abort()
|
||||||
|
* 5. 事件隔离 — SubAgent 的流式事件不直接转发到前端,仅通过 orchestrator 事件通知
|
||||||
*
|
*
|
||||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { AgentLoopEngine } from '../agent-loop/engine';
|
import { AgentLoopEngine } from '../agent-loop/engine';
|
||||||
import type { MetonaMessage, MetonaSystemPrompt } from '../types';
|
import type { AgentLoopConfig } from '../agent-loop/types';
|
||||||
|
import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
|
||||||
import type { ToolRegistry } from '../tools/registry';
|
import type { ToolRegistry } from '../tools/registry';
|
||||||
import type { MetonaToolDef } from '../types';
|
import type { PreToolHook } from '../hooks/pre-tool';
|
||||||
|
import type { PostToolHook } from '../hooks/post-tool';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
|
|
||||||
export interface SubAgentResult {
|
export interface SubAgentResult {
|
||||||
@@ -20,25 +29,34 @@ export interface SubAgentResult {
|
|||||||
result: string;
|
result: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
iterations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubAgentHandle {
|
interface SubAgentHandle {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: 'pending' | 'running' | 'completed' | 'error';
|
status: 'pending' | 'running' | 'completed' | 'error';
|
||||||
|
depth: number;
|
||||||
|
engine?: AgentLoopEngine;
|
||||||
result?: SubAgentResult;
|
result?: SubAgentResult;
|
||||||
abort: () => void;
|
abort: () => void;
|
||||||
onComplete: (callback: (result: SubAgentResult) => void) => void;
|
getStatus: () => { taskId: string; status: string; description: string; depth: number };
|
||||||
onError: (callback: (error: Error) => void) => void;
|
|
||||||
getStatus: () => { taskId: string; status: string; description: string };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 默认递归深度限制 */
|
||||||
|
const MAX_DELEGATION_DEPTH = 3;
|
||||||
|
|
||||||
export class TaskOrchestrator extends EventEmitter {
|
export class TaskOrchestrator extends EventEmitter {
|
||||||
private activeSubAgents = new Map<string, SubAgentHandle>();
|
private activeSubAgents = new Map<string, SubAgentHandle>();
|
||||||
|
/** 追踪每个 session 的当前委派深度 */
|
||||||
|
private sessionDepth = new Map<string, number>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private agentLoopEngine: AgentLoopEngine,
|
private mainEngine: AgentLoopEngine,
|
||||||
private toolRegistry?: ToolRegistry,
|
private toolRegistry?: ToolRegistry,
|
||||||
|
private preToolHooks: PreToolHook[] = [],
|
||||||
|
private postToolHooks: PostToolHook[] = [],
|
||||||
|
private defaultConfig?: Partial<AgentLoopConfig>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -46,8 +64,8 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* 委派子任务
|
* 委派子任务
|
||||||
*
|
*
|
||||||
* 创建一个独立的 SubAgent 执行上下文,调用 AgentLoopEngine 完成任务。
|
* 创建一个独立的 AgentLoopEngine 实例执行子任务。
|
||||||
* 支持限制可用工具列表(白名单)。
|
* SubAgent 不共享主 Agent 的引擎状态,安全隔离。
|
||||||
*/
|
*/
|
||||||
async delegate(params: {
|
async delegate(params: {
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
@@ -59,25 +77,60 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
const taskId = params.taskId ?? `sub_${nanoid(8)}`;
|
const taskId = params.taskId ?? `sub_${nanoid(8)}`;
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId });
|
// ===== 递归深度检查 =====
|
||||||
|
const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
|
||||||
|
if (currentDepth >= MAX_DELEGATION_DEPTH) {
|
||||||
|
log.warn(`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`);
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
|
||||||
|
success: false,
|
||||||
|
durationMs: 0,
|
||||||
|
iterations: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const depth = currentDepth + 1;
|
||||||
|
this.sessionDepth.set(params.parentSessionId, depth);
|
||||||
|
|
||||||
|
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId, depth });
|
||||||
|
|
||||||
|
// ===== 创建独立的引擎实例 =====
|
||||||
|
const subEngine = new AgentLoopEngine(
|
||||||
|
{
|
||||||
|
maxIterations: params.maxIterations ?? 10,
|
||||||
|
totalTimeoutMs: 300_000, // 子任务总超时 5 分钟
|
||||||
|
thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true,
|
||||||
|
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
|
||||||
|
contextLength: this.defaultConfig?.contextLength,
|
||||||
|
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
|
||||||
|
},
|
||||||
|
this.mainEngine.getAdapter(),
|
||||||
|
this.toolRegistry,
|
||||||
|
this.preToolHooks,
|
||||||
|
this.postToolHooks,
|
||||||
|
);
|
||||||
|
subEngine.setWorkspacePath(this.mainEngine.getWorkspacePath());
|
||||||
|
|
||||||
|
// ===== 工具白名单设置 =====
|
||||||
|
const allowedTools = this.resolveTools(params.tools);
|
||||||
|
subEngine.setTools(allowedTools);
|
||||||
|
|
||||||
const handle: SubAgentHandle = {
|
const handle: SubAgentHandle = {
|
||||||
taskId,
|
taskId,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
depth,
|
||||||
|
engine: subEngine,
|
||||||
abort: () => {
|
abort: () => {
|
||||||
|
subEngine.abort();
|
||||||
handle.status = 'error';
|
handle.status = 'error';
|
||||||
this.activeSubAgents.delete(taskId);
|
this.activeSubAgents.delete(taskId);
|
||||||
},
|
},
|
||||||
onComplete: (callback) => {
|
getStatus: () => ({ taskId, status: handle.status, description: handle.description, depth: handle.depth }),
|
||||||
if (handle.result) callback(handle.result);
|
|
||||||
},
|
|
||||||
onError: (callback) => { /* errors captured by try/catch below */ },
|
|
||||||
getStatus: () => ({ taskId, status: handle.status, description: handle.description }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activeSubAgents.set(taskId, handle);
|
this.activeSubAgents.set(taskId, handle);
|
||||||
this.emit('taskStarted', { taskId });
|
this.emit('taskStarted', { taskId, depth });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 构建用户消息
|
// 构建用户消息
|
||||||
@@ -87,49 +140,16 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建 System Prompt(子 Agent 简化版)
|
// 构建 System Prompt(子 Agent 专用)
|
||||||
const systemPrompt: MetonaSystemPrompt = {
|
const systemPrompt = this.buildSubAgentPrompt(params.description, depth);
|
||||||
roleDefinition: '你是一个子任务执行 Agent,负责完成被委派的单一任务。',
|
|
||||||
outputConstraints: '用中文回答,简洁准确地完成任务。',
|
|
||||||
safetyGuidelines: '不访问工作空间外的文件,不执行危险命令。',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 保存原始工具列表(子任务完成后恢复)
|
|
||||||
const savedTools = this.agentLoopEngine.getTools?.() ?? [];
|
|
||||||
|
|
||||||
// 如果指定了工具白名单,设置受限工具集
|
|
||||||
if (params.tools && params.tools.length > 0 && this.toolRegistry) {
|
|
||||||
const allowedTools: MetonaToolDef[] = [];
|
|
||||||
for (const toolName of params.tools) {
|
|
||||||
const tool = this.toolRegistry.get(toolName);
|
|
||||||
if (tool) {
|
|
||||||
allowedTools.push({
|
|
||||||
name: tool.definition.name,
|
|
||||||
description: tool.definition.description,
|
|
||||||
parameters: tool.definition.parameters,
|
|
||||||
category: tool.definition.category,
|
|
||||||
riskLevel: tool.definition.riskLevel,
|
|
||||||
requiresPermission: tool.definition.requiresPermission,
|
|
||||||
timeoutMs: tool.definition.timeoutMs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.agentLoopEngine.setTools(allowedTools);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 运行 Agent Loop(同步等待完成)
|
// 运行 Agent Loop(同步等待完成)
|
||||||
let output;
|
const output = await subEngine.runStream(
|
||||||
try {
|
|
||||||
output = await this.agentLoopEngine.runStream(
|
|
||||||
userMessage,
|
userMessage,
|
||||||
taskId,
|
taskId,
|
||||||
[], // 子 Agent 无历史
|
[], // 子 Agent 无历史
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
// 恢复主 Agent 的原始工具列表
|
|
||||||
this.agentLoopEngine.setTools(savedTools);
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
const durationMs = Date.now() - startMs;
|
||||||
const success = output.terminationReason === 'completed';
|
const success = output.terminationReason === 'completed';
|
||||||
@@ -138,14 +158,16 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
result: output.finalAnswer,
|
result: output.finalAnswer,
|
||||||
success,
|
success,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
iterations: output.iterations.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.status = success ? 'completed' : 'error';
|
handle.status = success ? 'completed' : 'error';
|
||||||
handle.result = result;
|
handle.result = result;
|
||||||
this.activeSubAgents.delete(taskId);
|
this.activeSubAgents.delete(taskId);
|
||||||
|
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
|
||||||
this.emit('taskCompleted', result);
|
this.emit('taskCompleted', result);
|
||||||
|
|
||||||
log.info(`[Orchestrator] SubAgent "${taskId}" ${success ? 'completed' : 'failed'} in ${durationMs}ms`);
|
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,40 +178,107 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
result: errMsg,
|
result: errMsg,
|
||||||
success: false,
|
success: false,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
iterations: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.status = 'error';
|
handle.status = 'error';
|
||||||
handle.result = result;
|
handle.result = result;
|
||||||
this.activeSubAgents.delete(taskId);
|
this.activeSubAgents.delete(taskId);
|
||||||
|
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
|
||||||
this.emit('taskError', { taskId, error: errMsg });
|
this.emit('taskError', { taskId, error: errMsg });
|
||||||
|
|
||||||
log.error(`[Orchestrator] SubAgent "${taskId}" error: ${errMsg}`);
|
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 完成子任务(外部触发)
|
* 解析工具白名单
|
||||||
|
*
|
||||||
|
* - 如果指定了 tools,使用白名单(自动排除 delegate_task 防止递归)
|
||||||
|
* - 如果未指定,使用所有已启用工具(同样排除 delegate_task)
|
||||||
|
*/
|
||||||
|
private resolveTools(toolNames?: string[]): MetonaToolDef[] {
|
||||||
|
if (!this.toolRegistry) return [];
|
||||||
|
|
||||||
|
// 始终排除 delegate_task 防止递归(除非深度为 1 且显式要求)
|
||||||
|
const EXCLUDE_TOOLS = new Set(['delegate_task']);
|
||||||
|
|
||||||
|
if (toolNames && toolNames.length > 0) {
|
||||||
|
// 使用白名单模式
|
||||||
|
const resolved: MetonaToolDef[] = [];
|
||||||
|
const notFound: string[] = [];
|
||||||
|
|
||||||
|
for (const name of toolNames) {
|
||||||
|
if (EXCLUDE_TOOLS.has(name)) continue; // 静默排除
|
||||||
|
const tool = this.toolRegistry.get(name);
|
||||||
|
if (tool) {
|
||||||
|
resolved.push(tool.definition);
|
||||||
|
} else {
|
||||||
|
notFound.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound.length > 0) {
|
||||||
|
log.warn(`[Orchestrator] Tools not found: ${notFound.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未指定白名单 — 使用所有已启用工具(排除 delegate_task)
|
||||||
|
return this.toolRegistry.listTools().filter((t) => !EXCLUDE_TOOLS.has(t.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 SubAgent 的 System Prompt
|
||||||
|
*/
|
||||||
|
private buildSubAgentPrompt(description: string, depth: number): MetonaSystemPrompt {
|
||||||
|
return {
|
||||||
|
roleDefinition: `You are a SubAgent (delegation depth: ${depth}) executing a specific sub-task delegated by the parent Agent.\nYour goal is to complete the assigned task efficiently and return a clear, concise result.\nFocus only on the task at hand. Do not delegate further.`,
|
||||||
|
outputConstraints: `Complete the task and provide a clear summary of your findings or actions.\nRespond in the same language as the task description.\nKeep your response focused and relevant — the parent Agent will use your result to continue its work.`,
|
||||||
|
safetyGuidelines: `Do not access files outside the workspace.\nDo not execute dangerous commands.\nIf the task cannot be completed, explain why clearly.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中断指定子任务
|
||||||
|
*/
|
||||||
|
abortTask(taskId: string): boolean {
|
||||||
|
const handle = this.activeSubAgents.get(taskId);
|
||||||
|
if (handle && handle.status === 'running') {
|
||||||
|
handle.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成子任务(外部触发,保留接口兼容)
|
||||||
*/
|
*/
|
||||||
completeTask(taskId: string, result: string, success: boolean): void {
|
completeTask(taskId: string, result: string, success: boolean): void {
|
||||||
const handle = this.activeSubAgents.get(taskId);
|
const handle = this.activeSubAgents.get(taskId);
|
||||||
if (handle && handle.status === 'running') {
|
if (handle && handle.status === 'running') {
|
||||||
handle.status = success ? 'completed' : 'error';
|
handle.status = success ? 'completed' : 'error';
|
||||||
handle.result = { taskId, result, success, durationMs: 0 };
|
handle.result = { taskId, result, success, durationMs: 0, iterations: 0 };
|
||||||
this.activeSubAgents.delete(taskId);
|
this.activeSubAgents.delete(taskId);
|
||||||
this.emit('taskCompleted', handle.result);
|
this.emit('taskCompleted', handle.result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string }> {
|
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string; depth: number }> {
|
||||||
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
|
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中断所有子任务
|
||||||
|
*/
|
||||||
abortAll(): void {
|
abortAll(): void {
|
||||||
for (const agent of this.activeSubAgents.values()) {
|
for (const agent of this.activeSubAgents.values()) {
|
||||||
agent.abort();
|
agent.abort();
|
||||||
}
|
}
|
||||||
this.activeSubAgents.clear();
|
this.activeSubAgents.clear();
|
||||||
|
this.sessionDepth.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,16 +31,9 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
|
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
|
||||||
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
|
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
|
||||||
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
|
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
|
||||||
// Browser 浏览器工具
|
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
|
||||||
{ toolName: 'browser_open', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
|
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
|
||||||
{ toolName: 'browser_screenshot', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
|
{ toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
|
||||||
{ toolName: 'browser_evaluate', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
|
|
||||||
{ toolName: 'browser_extract', requiredLevel: PermissionLevel.READ },
|
|
||||||
{ toolName: 'browser_click', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
|
|
||||||
{ toolName: 'browser_type', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
|
|
||||||
{ toolName: 'browser_scroll', requiredLevel: PermissionLevel.READ },
|
|
||||||
{ toolName: 'browser_wait', requiredLevel: PermissionLevel.READ },
|
|
||||||
{ toolName: 'browser_close', requiredLevel: PermissionLevel.READ },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PolicyEngine {
|
export class PolicyEngine {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { resolve, sep } from 'path';
|
||||||
|
|
||||||
export interface SandboxConfig {
|
export interface SandboxConfig {
|
||||||
allowedPaths?: string[];
|
allowedPaths?: string[];
|
||||||
networkPolicy?: 'allowall' | 'deny-all' | 'allowlist';
|
networkPolicy?: 'allowall' | 'deny-all' | 'allowlist';
|
||||||
@@ -45,21 +47,22 @@ export class SandboxManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验文件路径是否在白名单内
|
* 校验文件路径是否在白名单内
|
||||||
|
*
|
||||||
|
* 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。
|
||||||
*/
|
*/
|
||||||
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
|
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
|
||||||
const { resolve } = require('path');
|
|
||||||
const resolved = resolve(requestedPath);
|
const resolved = resolve(requestedPath);
|
||||||
|
|
||||||
if (requestedPath.includes('..')) {
|
if (this.allowedPaths.size === 0) {
|
||||||
return { allowed: false, resolvedPath: resolved, reason: 'Path traversal detected' };
|
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.allowedPaths.size > 0) {
|
const isAllowed = Array.from(this.allowedPaths).some(
|
||||||
const isAllowed = Array.from(this.allowedPaths).some((allowed) => resolved.startsWith(allowed));
|
(allowed) => resolved === allowed || resolved.startsWith(allowed + sep),
|
||||||
|
);
|
||||||
if (!isAllowed) {
|
if (!isAllowed) {
|
||||||
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
|
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { allowed: true, resolvedPath: resolved };
|
return { allowed: true, resolvedPath: resolved };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* browser — 浏览器交互工具集(9 个函数)
|
* web_browser — 统一浏览器交互工具
|
||||||
*
|
*
|
||||||
* 基于单例 BrowserWindowManager 实现网页加载、截图、JS 执行、
|
* 将 9 个浏览器操作合并为单个工具,通过 `action` 参数路由:
|
||||||
* 内容提取、点击、输入、滚动、等待、关闭等操作。
|
* 1. open — 打开 URL
|
||||||
|
* 2. screenshot — 截图(视口/元素/全页三模式)
|
||||||
|
* 3. evaluate — 执行 JavaScript
|
||||||
|
* 4. extract — 提取页面文本与链接
|
||||||
|
* 5. click — 点击元素
|
||||||
|
* 6. type — 输入文本
|
||||||
|
* 7. scroll — 滚动页面
|
||||||
|
* 8. wait — 等待条件
|
||||||
|
* 9. close — 关闭窗口
|
||||||
*
|
*
|
||||||
* 工具列表:
|
* 基于单例 BrowserWindowManager 实现。
|
||||||
* 1. browser_open — 打开 URL
|
|
||||||
* 2. browser_screenshot — 截图(视口/元素/全页三模式)
|
|
||||||
* 3. browser_evaluate — 执行 JavaScript
|
|
||||||
* 4. browser_extract — 提取页面文本与链接
|
|
||||||
* 5. browser_click — 点击元素
|
|
||||||
* 6. browser_type — 输入文本
|
|
||||||
* 7. browser_scroll — 滚动页面
|
|
||||||
* 8. browser_wait — 等待条件
|
|
||||||
* 9. browser_close — 关闭窗口
|
|
||||||
*
|
*
|
||||||
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
|
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
|
||||||
*/
|
*/
|
||||||
@@ -35,354 +34,241 @@ function getManager(): BrowserWindowManager {
|
|||||||
return managerInstance;
|
return managerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取共享浏览器管理器单例(供 web_fetch 等工具复用) */
|
||||||
|
export function getBrowserManager(): BrowserWindowManager {
|
||||||
|
return getManager();
|
||||||
|
}
|
||||||
|
|
||||||
/** 应用退出时清理(由 main.ts 调用) */
|
/** 应用退出时清理(由 main.ts 调用) */
|
||||||
export function cleanupBrowser(): void {
|
export function cleanupBrowser(): void {
|
||||||
BrowserWindowManager.cleanup(managerInstance);
|
BrowserWindowManager.cleanup(managerInstance);
|
||||||
managerInstance = null;
|
managerInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 1. browser_open =====
|
// ===== WebBrowserTool =====
|
||||||
|
|
||||||
export class BrowserOpenTool implements IMetonaTool {
|
export class WebBrowserTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'browser_open',
|
name: 'web_browser',
|
||||||
description: 'Open a URL in a hidden browser window. Loads the page and optionally waits for a selector to appear. Returns the page title and URL.',
|
description: [
|
||||||
|
'Control a hidden browser window to navigate, inspect, and interact with web pages.',
|
||||||
|
'Use the "action" parameter to specify the operation:',
|
||||||
|
' open — Load a URL; optionally wait for a CSS selector before returning. Returns page title and URL.',
|
||||||
|
' screenshot — Capture the viewport (default), full page, or a specific element by CSS selector. Returns base64 PNG.',
|
||||||
|
' evaluate — Execute JavaScript in the page context. Returns the result of the last expression.',
|
||||||
|
' extract — Extract clean text and up to 50 links from the page or a specific element.',
|
||||||
|
' click — Click an element by CSS selector. Scrolls into view first. Can optionally wait for the selector.',
|
||||||
|
' type — Type text into an input element. Optionally clear first and submit the form.',
|
||||||
|
' scroll — Scroll the page: down/up (500px), top/bottom, or scroll to an element by selector.',
|
||||||
|
' wait — Wait for a CSS selector to appear (with timeout) or a fixed duration.',
|
||||||
|
' close — Close the browser window and release resources.',
|
||||||
|
].join('\n'),
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
url: { type: 'string', description: 'Target URL (http/https only)' },
|
action: {
|
||||||
wait_selector: { type: 'string', description: 'CSS selector to wait for before returning (optional)' },
|
type: 'string',
|
||||||
|
enum: ['open', 'screenshot', 'evaluate', 'extract', 'click', 'type', 'scroll', 'wait', 'close'],
|
||||||
|
description: 'Browser operation to perform',
|
||||||
},
|
},
|
||||||
required: ['url'],
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
description: '[open] Target URL (http/https only)',
|
||||||
|
},
|
||||||
|
wait_selector: {
|
||||||
|
type: 'string',
|
||||||
|
description: '[open] CSS selector to wait for before returning (optional)',
|
||||||
|
},
|
||||||
|
script: {
|
||||||
|
type: 'string',
|
||||||
|
description: '[evaluate] JavaScript code to execute (must return a value)',
|
||||||
|
},
|
||||||
|
selector: {
|
||||||
|
type: 'string',
|
||||||
|
description: '[screenshot|extract|click|type|scroll] CSS selector of the target element',
|
||||||
|
},
|
||||||
|
full_page: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '[screenshot] Capture entire scrollable page (default false)',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: 'string',
|
||||||
|
description: '[type] Text to type into the element',
|
||||||
|
},
|
||||||
|
clear: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '[type] Clear the field before typing (default true)',
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '[type] Submit the form after typing (default false)',
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['down', 'up', 'top', 'bottom'],
|
||||||
|
description: '[scroll] Scroll direction (default down). Ignored if selector is provided.',
|
||||||
|
},
|
||||||
|
wait: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '[click] Wait for selector to appear before clicking (default false)',
|
||||||
|
},
|
||||||
|
time_ms: {
|
||||||
|
type: 'number',
|
||||||
|
description: '[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['action'],
|
||||||
},
|
},
|
||||||
category: MetonaToolCategory.NETWORK,
|
category: MetonaToolCategory.NETWORK,
|
||||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
riskLevel: MetonaRiskLevel.HIGH,
|
||||||
requiresPermission: true,
|
requiresPermission: true,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const action = args.action as string;
|
||||||
|
if (!action) {
|
||||||
|
return { success: false, error: 'Missing required parameter: action' };
|
||||||
|
}
|
||||||
|
|
||||||
|
logTool('web_browser', `action=${action}`);
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
// ===== open =====
|
||||||
|
case 'open': {
|
||||||
const url = args.url as string;
|
const url = args.url as string;
|
||||||
if (!url || !/^https?:\/\//i.test(url)) {
|
if (!url || !/^https?:\/\//i.test(url)) {
|
||||||
return { success: false, error: 'URL must start with http:// or https://' };
|
return { success: false, error: 'URL must start with http:// or https://' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitSelector = args.wait_selector as string | undefined;
|
const waitSelector = args.wait_selector as string | undefined;
|
||||||
logTool('browser_open', `Opening: ${url}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getManager().open({ url, waitSelector });
|
const result = await getManager().open({ url, waitSelector });
|
||||||
return { success: true, ...result };
|
return { success: true, action, ...result };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 2. browser_screenshot =====
|
// ===== screenshot =====
|
||||||
|
case 'screenshot': {
|
||||||
export class BrowserScreenshotTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_screenshot',
|
|
||||||
description: 'Capture a screenshot of the current browser window. Three modes: viewport (default), full page, or specific element by CSS selector. Returns base64-encoded PNG image.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
full_page: { type: 'boolean', description: 'Capture entire scrollable page (default false)' },
|
|
||||||
selector: { type: 'string', description: 'CSS selector to capture a specific element (overrides full_page)' },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.LOW,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const fullPage = (args.full_page as boolean) ?? false;
|
const fullPage = (args.full_page as boolean) ?? false;
|
||||||
const selector = args.selector as string | undefined;
|
const selector = args.selector as string | undefined;
|
||||||
|
|
||||||
logTool('browser_screenshot', `full_page=${fullPage}, selector=${selector ?? 'none'}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getManager().screenshot({ fullPage, selector });
|
const result = await getManager().screenshot({ fullPage, selector });
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
action,
|
||||||
image: result.data,
|
image: result.data,
|
||||||
width: result.width,
|
width: result.width,
|
||||||
height: result.height,
|
height: result.height,
|
||||||
mime_type: 'image/png',
|
mime_type: 'image/png',
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 3. browser_evaluate =====
|
// ===== evaluate =====
|
||||||
|
case 'evaluate': {
|
||||||
export class BrowserEvaluateTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_evaluate',
|
|
||||||
description: 'Execute JavaScript code in the current browser page context. Returns the result of the last expression. Use for data extraction, DOM queries, or triggering page actions.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
script: { type: 'string', description: 'JavaScript code to execute (must return a value)' },
|
|
||||||
},
|
|
||||||
required: ['script'],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.HIGH,
|
|
||||||
requiresPermission: true,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const script = args.script as string;
|
const script = args.script as string;
|
||||||
if (!script) {
|
if (!script) {
|
||||||
return { success: false, error: 'No script provided' };
|
return { success: false, action, error: 'No script provided' };
|
||||||
}
|
}
|
||||||
|
|
||||||
logTool('browser_evaluate', `Executing ${script.length} chars of JS`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getManager().evaluate(script);
|
const result = await getManager().evaluate(script);
|
||||||
return { success: true, result };
|
return { success: true, action, result };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 4. browser_extract =====
|
// ===== extract =====
|
||||||
|
case 'extract': {
|
||||||
export class BrowserExtractTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_extract',
|
|
||||||
description: 'Extract text content and links from the current browser page. Optionally target a specific element by CSS selector. Strips scripts, styles, and navigation elements. Returns clean text and up to 50 links.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
selector: { type: 'string', description: 'CSS selector to extract from (default: entire body)' },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.LOW,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const selector = args.selector as string | undefined;
|
const selector = args.selector as string | undefined;
|
||||||
|
|
||||||
logTool('browser_extract', `selector=${selector ?? 'body'}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getManager().extract(selector);
|
const result = await getManager().extract(selector);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
action,
|
||||||
text: result.text,
|
text: result.text,
|
||||||
links: result.links,
|
links: result.links,
|
||||||
link_count: result.links.length,
|
link_count: result.links.length,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 5. browser_click =====
|
// ===== click =====
|
||||||
|
case 'click': {
|
||||||
export class BrowserClickTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_click',
|
|
||||||
description: 'Click an element on the page by CSS selector. Scrolls the element into view before clicking. Optionally wait for the selector to appear first.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
selector: { type: 'string', description: 'CSS selector of the element to click' },
|
|
||||||
wait: { type: 'boolean', description: 'Wait for selector to appear before clicking (default false)' },
|
|
||||||
},
|
|
||||||
required: ['selector'],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
|
||||||
requiresPermission: true,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const selector = args.selector as string;
|
const selector = args.selector as string;
|
||||||
const wait = (args.wait as boolean) ?? false;
|
const wait = (args.wait as boolean) ?? false;
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
return { success: false, error: 'No selector provided' };
|
return { success: false, action, error: 'No selector provided' };
|
||||||
}
|
}
|
||||||
|
|
||||||
logTool('browser_click', `selector=${selector}, wait=${wait}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getManager().click(selector, wait);
|
await getManager().click(selector, wait);
|
||||||
return { success: true, selector, clicked: true };
|
return { success: true, action, selector, clicked: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 6. browser_type =====
|
// ===== type =====
|
||||||
|
case 'type': {
|
||||||
export class BrowserTypeTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_type',
|
|
||||||
description: 'Type text into an input element by CSS selector. Focuses the element, optionally clears it first, and dispatches input/change events for React/Vue compatibility. Can optionally submit the form.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
selector: { type: 'string', description: 'CSS selector of the input element' },
|
|
||||||
text: { type: 'string', description: 'Text to type into the element' },
|
|
||||||
clear: { type: 'boolean', description: 'Clear the field before typing (default true)' },
|
|
||||||
submit: { type: 'boolean', description: 'Submit the form after typing (default false)' },
|
|
||||||
},
|
|
||||||
required: ['selector', 'text'],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
|
||||||
requiresPermission: true,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const selector = args.selector as string;
|
const selector = args.selector as string;
|
||||||
const text = args.text as string;
|
const text = args.text as string;
|
||||||
const clear = (args.clear as boolean) ?? true;
|
const clear = (args.clear as boolean) ?? true;
|
||||||
const submit = (args.submit as boolean) ?? false;
|
const submit = (args.submit as boolean) ?? false;
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
return { success: false, error: 'No selector provided' };
|
return { success: false, action, error: 'No selector provided' };
|
||||||
}
|
}
|
||||||
if (text === undefined || text === null) {
|
if (text === undefined || text === null) {
|
||||||
return { success: false, error: 'No text provided' };
|
return { success: false, action, error: 'No text provided' };
|
||||||
}
|
}
|
||||||
|
|
||||||
logTool('browser_type', `selector=${selector}, len=${text.length}, submit=${submit}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getManager().type(selector, text, { clear, submit });
|
await getManager().type(selector, text, { clear, submit });
|
||||||
return { success: true, selector, typed: text.length };
|
return { success: true, action, selector, typed: text.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 7. browser_scroll =====
|
// ===== scroll =====
|
||||||
|
case 'scroll': {
|
||||||
export class BrowserScrollTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_scroll',
|
|
||||||
description: 'Scroll the browser page. Modes: down/up (500px increment), top/bottom (absolute), or scroll to a specific element by CSS selector.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
direction: { type: 'string', description: 'Scroll direction: down, up, top, bottom (default down)' },
|
|
||||||
selector: { type: 'string', description: 'CSS selector to scroll to (overrides direction)' },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.LOW,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 15_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const direction = (args.direction as string) ?? 'down';
|
const direction = (args.direction as string) ?? 'down';
|
||||||
const selector = args.selector as string | undefined;
|
const selector = args.selector as string | undefined;
|
||||||
|
|
||||||
logTool('browser_scroll', `direction=${direction}, selector=${selector ?? 'none'}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getManager().scroll({ direction: direction as 'down' | 'up' | 'top' | 'bottom', selector });
|
await getManager().scroll({
|
||||||
return { success: true, direction, selector };
|
direction: direction as 'down' | 'up' | 'top' | 'bottom',
|
||||||
|
selector,
|
||||||
|
});
|
||||||
|
return { success: true, action, direction, selector };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 8. browser_wait =====
|
// ===== wait =====
|
||||||
|
case 'wait': {
|
||||||
export class BrowserWaitTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_wait',
|
|
||||||
description: 'Wait for a condition on the browser page. Either wait for a CSS selector to appear (with timeout) or wait for a fixed duration.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
selector: { type: 'string', description: 'CSS selector to wait for (mutually exclusive with time_ms)' },
|
|
||||||
time_ms: { type: 'number', description: 'Fixed wait time in milliseconds (default 1000)' },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.LOW,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 30_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
const selector = args.selector as string | undefined;
|
const selector = args.selector as string | undefined;
|
||||||
const timeMs = (args.time_ms as number) ?? 1_000;
|
const timeMs = (args.time_ms as number) ?? 1_000;
|
||||||
|
|
||||||
logTool('browser_wait', `selector=${selector ?? 'none'}, time_ms=${timeMs}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getManager().wait({ selector, timeMs });
|
await getManager().wait({ selector, timeMs });
|
||||||
return { success: true, waited_for: selector ?? `${timeMs}ms` };
|
return { success: true, action, waited_for: selector ?? `${timeMs}ms` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, action, error: (err as Error).message };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 9. browser_close =====
|
// ===== close =====
|
||||||
|
case 'close': {
|
||||||
export class BrowserCloseTool implements IMetonaTool {
|
|
||||||
readonly definition: MetonaToolDef = {
|
|
||||||
name: 'browser_close',
|
|
||||||
description: 'Close the current browser window and release resources. Call this when browser interaction is complete to free memory.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
category: MetonaToolCategory.NETWORK,
|
|
||||||
riskLevel: MetonaRiskLevel.LOW,
|
|
||||||
requiresPermission: false,
|
|
||||||
timeoutMs: 10_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(_args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
|
||||||
logTool('browser_close', 'Closing browser window');
|
|
||||||
getManager().close();
|
getManager().close();
|
||||||
return { success: true, closed: true };
|
return { success: true, action, closed: true };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 导出所有 Browser 工具 =====
|
default:
|
||||||
|
return { success: false, error: `Unknown action: ${action}` };
|
||||||
export const browserTools: IMetonaTool[] = [
|
}
|
||||||
new BrowserOpenTool(),
|
}
|
||||||
new BrowserScreenshotTool(),
|
}
|
||||||
new BrowserEvaluateTool(),
|
|
||||||
new BrowserExtractTool(),
|
|
||||||
new BrowserClickTool(),
|
|
||||||
new BrowserTypeTool(),
|
|
||||||
new BrowserScrollTool(),
|
|
||||||
new BrowserWaitTool(),
|
|
||||||
new BrowserCloseTool(),
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import { resolve } from 'path';
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
import { commandTouchesProtectedFile } from './file-guard';
|
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -42,7 +43,13 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
const workdir = (args.workdir as string) ?? context.workspacePath;
|
const workdir = (args.workdir as string) ?? context.workspacePath;
|
||||||
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
|
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
|
||||||
|
|
||||||
// 安全校验
|
// 安全校验:workdir 必须在工作空间内
|
||||||
|
const resolvedWorkdir = resolve(context.workspacePath, workdir);
|
||||||
|
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
|
||||||
|
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 命令安全校验
|
||||||
const validation = this.validateCommand(command);
|
const validation = this.validateCommand(command);
|
||||||
if (!validation.allowed) {
|
if (!validation.allowed) {
|
||||||
return { success: false, error: validation.reason, command };
|
return { success: false, error: validation.reason, command };
|
||||||
@@ -50,7 +57,7 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
const { stdout, stderr } = await execAsync(command, {
|
||||||
cwd: workdir,
|
cwd: resolvedWorkdir,
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer: 1024 * 1024, // 1MB
|
maxBuffer: 1024 * 1024, // 1MB
|
||||||
env: { ...process.env, NODE_ENV: 'production' },
|
env: { ...process.env, NODE_ENV: 'production' },
|
||||||
@@ -99,6 +106,14 @@ export class RunCommandTool implements IMetonaTool {
|
|||||||
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
||||||
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
||||||
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
|
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
|
||||||
|
// Windows 危险命令
|
||||||
|
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
||||||
|
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
|
||||||
|
{ pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' },
|
||||||
|
{ pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' },
|
||||||
|
// 后台进程与管道炸弹
|
||||||
|
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
|
||||||
|
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const block of hardBlocks) {
|
for (const block of hardBlocks) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 子任务委派工具
|
||||||
|
*
|
||||||
|
* 允许主 Agent 将子任务委派给独立的 SubAgent 执行。
|
||||||
|
* SubAgent 运行在独立的 AgentLoopEngine 实例中,可限制可用工具。
|
||||||
|
*
|
||||||
|
* @see electron/harness/orchestration/orchestrator.ts — TaskOrchestrator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
|
import type { TaskOrchestrator } from '../../orchestration/orchestrator';
|
||||||
|
|
||||||
|
export class DelegateTaskTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'delegate_task',
|
||||||
|
description: 'Delegate a sub-task to an independent SubAgent for parallel or isolated execution. The SubAgent runs its own ReAct loop and returns the final result. Use this for complex sub-tasks that benefit from focused reasoning.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Clear and detailed description of the sub-task to delegate. This will be the SubAgent\'s user message.',
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Optional whitelist of tool names the SubAgent can use (e.g. ["read_file", "web_search"]). If omitted, SubAgent inherits all tools.',
|
||||||
|
},
|
||||||
|
maxIterations: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum iterations for the SubAgent (default: 10)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['description'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.CUSTOM,
|
||||||
|
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: 300_000, // 5 分钟,子任务可能较复杂
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private orchestrator: TaskOrchestrator) {}
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
const description = args.description as string;
|
||||||
|
const tools = args.tools as string[] | undefined;
|
||||||
|
const maxIterations = (args.maxIterations as number) ?? 10;
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
return { success: false, error: 'Missing required parameter: description' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.orchestrator.delegate({
|
||||||
|
description,
|
||||||
|
parentSessionId: context.sessionId,
|
||||||
|
maxIterations,
|
||||||
|
tools,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
result: result.result,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
taskId: result.taskId,
|
||||||
|
iterations: result.iterations,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SubAgent execution failed: ${(error as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile, writeFile, readdir, stat, appendFile } from 'fs/promises';
|
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open } from 'fs/promises';
|
||||||
import { join, relative, resolve } from 'path';
|
import { join, relative, resolve, dirname } from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
@@ -34,6 +34,29 @@ function safeResolvePath(filePath: string, workspacePath: string): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 共享 glob 匹配(简易通配符 → 正则) */
|
||||||
|
function matchGlob(name: string, glob: string): boolean {
|
||||||
|
const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||||
|
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
|
||||||
|
async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(8192);
|
||||||
|
const fd = await open(filePath, 'r');
|
||||||
|
await fd.read(buffer, 0, 8192, 0);
|
||||||
|
await fd.close();
|
||||||
|
// 含 NULL 字节 → 二进制
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
if (buffer[i] === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 1. read_file =====
|
// ===== 1. read_file =====
|
||||||
|
|
||||||
export class ReadFileTool implements IMetonaTool {
|
export class ReadFileTool implements IMetonaTool {
|
||||||
@@ -60,6 +83,18 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
const offset = Math.max(1, (args.offset as number) ?? 1);
|
const offset = Math.max(1, (args.offset as number) ?? 1);
|
||||||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
||||||
|
|
||||||
|
// 二进制文件检测 — 避免读取图片/可执行文件产生乱码
|
||||||
|
if (await isBinaryFile(filePath)) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
total_lines: 0,
|
||||||
|
returned_lines: 0,
|
||||||
|
truncated: false,
|
||||||
|
file_size: (await stat(filePath)).size,
|
||||||
|
error: 'Binary file detected. Use web_browser screenshot or other tools for binary content.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const content = await readFile(filePath, 'utf-8');
|
const content = await readFile(filePath, 'utf-8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
||||||
@@ -69,7 +104,7 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
total_lines: lines.length,
|
total_lines: lines.length,
|
||||||
returned_lines: slicedLines.length,
|
returned_lines: slicedLines.length,
|
||||||
truncated: lines.length > offset - 1 + limit,
|
truncated: lines.length > offset - 1 + limit,
|
||||||
file_size: content.length,
|
file_size: Buffer.byteLength(content, 'utf-8'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +135,12 @@ export class WriteFileTool implements IMetonaTool {
|
|||||||
const content = args.content as string;
|
const content = args.content as string;
|
||||||
const mode = (args.mode as string) ?? 'overwrite';
|
const mode = (args.mode as string) ?? 'overwrite';
|
||||||
|
|
||||||
|
// 自动创建父目录(递归)
|
||||||
|
const parentDir = dirname(filePath);
|
||||||
|
if (!existsSync(parentDir)) {
|
||||||
|
await mkdir(parentDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'append') {
|
if (mode === 'append') {
|
||||||
await appendFile(filePath, content, 'utf-8');
|
await appendFile(filePath, content, 'utf-8');
|
||||||
} else {
|
} else {
|
||||||
@@ -137,11 +178,17 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
||||||
const glob = args.glob as string | undefined;
|
const glob = args.glob as string | undefined;
|
||||||
|
|
||||||
const entries = await this.listDir(dirPath, depth, glob, 0);
|
const entries = await this.listDir(dirPath, dirPath, depth, glob, 0);
|
||||||
return { entries, count: entries.length };
|
return { entries, count: entries.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listDir(dirPath: string, maxDepth: number, glob: string | undefined, currentDepth: number): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
|
private async listDir(
|
||||||
|
rootPath: string,
|
||||||
|
dirPath: string,
|
||||||
|
maxDepth: number,
|
||||||
|
glob: string | undefined,
|
||||||
|
currentDepth: number,
|
||||||
|
): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
|
||||||
const results: Array<{ name: string; path: string; type: string; size?: number }> = [];
|
const results: Array<{ name: string; path: string; type: string; size?: number }> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -152,18 +199,19 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||||
|
|
||||||
const fullPath = join(dirPath, entry.name);
|
const fullPath = join(dirPath, entry.name);
|
||||||
const relativePath = relative(dirPath, fullPath);
|
// 相对路径始终以根请求目录为基准
|
||||||
|
const relativePath = relative(rootPath, fullPath);
|
||||||
// glob 过滤
|
|
||||||
if (glob && !this.matchGlob(entry.name, glob)) continue;
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
|
||||||
results.push({ name: entry.name, path: relativePath, type: 'directory' });
|
results.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||||
if (currentDepth < maxDepth - 1) {
|
if (currentDepth < maxDepth - 1) {
|
||||||
const subEntries = await this.listDir(fullPath, maxDepth, glob, currentDepth + 1);
|
const subEntries = await this.listDir(rootPath, fullPath, maxDepth, glob, currentDepth + 1);
|
||||||
results.push(...subEntries);
|
results.push(...subEntries);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// glob 过滤仅适用于文件
|
||||||
|
if (glob && !matchGlob(entry.name, glob)) continue;
|
||||||
const stats = await stat(fullPath);
|
const stats = await stat(fullPath);
|
||||||
results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size });
|
results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size });
|
||||||
}
|
}
|
||||||
@@ -174,11 +222,6 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchGlob(name: string, glob: string): boolean {
|
|
||||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
||||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 4. search_files =====
|
// ===== 4. search_files =====
|
||||||
@@ -221,12 +264,10 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
|
|
||||||
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
|
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
|
||||||
const results: Array<{ path: string; name: string }> = [];
|
const results: Array<{ path: string; name: string }> = [];
|
||||||
const globPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
||||||
const regex = new RegExp(globPattern, 'i');
|
|
||||||
|
|
||||||
await this.walkDir(dirPath, async (filePath, name) => {
|
await this.walkDir(dirPath, async (filePath, name) => {
|
||||||
if (results.length >= limit) return;
|
if (results.length >= limit) return;
|
||||||
if (regex.test(name)) {
|
if (matchGlob(name, pattern)) {
|
||||||
results.push({ path: relative(dirPath, filePath), name });
|
results.push({ path: relative(dirPath, filePath), name });
|
||||||
}
|
}
|
||||||
}, fileGlob);
|
}, fileGlob);
|
||||||
@@ -236,7 +277,17 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
|
|
||||||
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise<unknown> {
|
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise<unknown> {
|
||||||
const results: Array<{ path: string; line: number; match: string }> = [];
|
const results: Array<{ path: string; line: number; match: string }> = [];
|
||||||
const regex = new RegExp(pattern, 'gi');
|
|
||||||
|
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
|
||||||
|
if (pattern.length > 500) {
|
||||||
|
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)' };
|
||||||
|
}
|
||||||
|
let regex: RegExp;
|
||||||
|
try {
|
||||||
|
regex = new RegExp(pattern, 'gi');
|
||||||
|
} catch {
|
||||||
|
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}` };
|
||||||
|
}
|
||||||
|
|
||||||
await this.walkDir(dirPath, async (filePath) => {
|
await this.walkDir(dirPath, async (filePath) => {
|
||||||
if (results.length >= limit) return;
|
if (results.length >= limit) return;
|
||||||
@@ -280,7 +331,7 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.walkDir(fullPath, callback, fileGlob);
|
await this.walkDir(fullPath, callback, fileGlob);
|
||||||
} else {
|
} else {
|
||||||
if (fileGlob && !this.matchGlob(entry.name, fileGlob)) continue;
|
if (fileGlob && !matchGlob(entry.name, fileGlob)) continue;
|
||||||
await callback(fullPath, entry.name);
|
await callback(fullPath, entry.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,11 +340,6 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchGlob(name: string, glob: string): boolean {
|
|
||||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
||||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
|
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
|
||||||
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
||||||
const resolved = resolve(workspacePath, filePath);
|
const resolved = resolve(workspacePath, filePath);
|
||||||
|
|||||||
@@ -8,16 +8,5 @@ export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from
|
|||||||
export { WebSearchTool, WebFetchTool } from './network';
|
export { WebSearchTool, WebFetchTool } from './network';
|
||||||
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
||||||
export { RunCommandTool } from './command';
|
export { RunCommandTool } from './command';
|
||||||
export {
|
export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser';
|
||||||
BrowserOpenTool,
|
export { DelegateTaskTool } from './delegate-task';
|
||||||
BrowserScreenshotTool,
|
|
||||||
BrowserEvaluateTool,
|
|
||||||
BrowserExtractTool,
|
|
||||||
BrowserClickTool,
|
|
||||||
BrowserTypeTool,
|
|
||||||
BrowserScrollTool,
|
|
||||||
BrowserWaitTool,
|
|
||||||
BrowserCloseTool,
|
|
||||||
browserTools,
|
|
||||||
cleanupBrowser,
|
|
||||||
} from './browser';
|
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ export class MemoryStoreTool implements IMetonaTool {
|
|||||||
content: { type: 'string', description: 'The memory content to store' },
|
content: { type: 'string', description: 'The memory content to store' },
|
||||||
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] },
|
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] },
|
||||||
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
|
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
|
||||||
source: { type: 'string', description: 'Source identifier (default "agent")' },
|
source: { type: 'string', description: 'Source of the memory', enum: ['user_input', 'tool_result', 'agent_thought', 'imported'] },
|
||||||
tags: { type: 'array', description: 'Tag list for categorization', items: { type: 'string', description: 'Tag' } },
|
|
||||||
},
|
},
|
||||||
required: ['content', 'type'],
|
required: ['content', 'type'],
|
||||||
},
|
},
|
||||||
@@ -40,13 +39,12 @@ export class MemoryStoreTool implements IMetonaTool {
|
|||||||
const content = args.content as string;
|
const content = args.content as string;
|
||||||
const type = args.type as 'episodic' | 'semantic' | 'working';
|
const type = args.type as 'episodic' | 'semantic' | 'working';
|
||||||
const importance = (args.importance as number) ?? 0.5;
|
const importance = (args.importance as number) ?? 0.5;
|
||||||
const source = (args.source as string) ?? 'agent';
|
const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought';
|
||||||
const tags = (args.tags as string[]) ?? [];
|
|
||||||
|
|
||||||
const id = await this.memoryManager.store({
|
const id = await this.memoryManager.store({
|
||||||
type,
|
type,
|
||||||
content,
|
content,
|
||||||
source: source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported',
|
source,
|
||||||
importance,
|
importance,
|
||||||
sessionId: context.sessionId,
|
sessionId: context.sessionId,
|
||||||
});
|
});
|
||||||
@@ -67,7 +65,7 @@ export class MemorySearchTool implements IMetonaTool {
|
|||||||
query: { type: 'string', description: 'Search query or keywords' },
|
query: { type: 'string', description: 'Search query or keywords' },
|
||||||
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] },
|
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] },
|
||||||
topK: { type: 'number', description: 'Number of results (default 5)' },
|
topK: { type: 'number', description: 'Number of results (default 5)' },
|
||||||
threshold: { type: 'number', description: 'Minimum relevance score 0-1 (default 0.7)' },
|
threshold: { type: 'number', description: 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.' },
|
||||||
},
|
},
|
||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
* 三阶段回退策略:
|
* 三阶段回退策略:
|
||||||
* Phase 1: HTTP 抓取(UA 轮换 + 反爬请求头 + 指数退避重试 + 拦截检测)
|
* Phase 1: HTTP 抓取(UA 轮换 + 反爬请求头 + 指数退避重试 + 拦截检测)
|
||||||
* Phase 2: 内容过短自动升级(< 200 字符 → 浏览器渲染)
|
* Phase 2: 内容过短自动升级(< 200 字符 → 浏览器渲染)
|
||||||
* Phase 3: 浏览器回退(隐藏 BrowserWindow + JS 渲染 + 内容提取)
|
* Phase 3: 浏览器回退(共享 BrowserWindowManager + JS 渲染 + 内容提取)
|
||||||
|
*
|
||||||
|
* 浏览器回退使用与 web_browser 相同的 BrowserWindowManager 单例,
|
||||||
|
* 避免创建多个独立浏览器窗口,支持窗口复用。
|
||||||
*
|
*
|
||||||
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
|
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
readBodyWithLimit,
|
readBodyWithLimit,
|
||||||
logTool,
|
logTool,
|
||||||
} from './network-utils';
|
} from './network-utils';
|
||||||
|
import { getBrowserManager } from './browser';
|
||||||
|
|
||||||
// ===== 跳过重试的状态码 =====
|
// ===== 跳过重试的状态码 =====
|
||||||
|
|
||||||
@@ -57,6 +60,13 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
|
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先查缓存(HTTP 和浏览器阶段共享同一缓存)
|
||||||
|
const cached = fetchCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
logTool('web_fetch', `Cache hit: ${url}`);
|
||||||
|
return { url, content: cached, success: true, method: 'cache', length: cached.length };
|
||||||
|
}
|
||||||
|
|
||||||
logTool('web_fetch', `Fetching: ${url}`);
|
logTool('web_fetch', `Fetching: ${url}`);
|
||||||
|
|
||||||
// ===== Phase 1: HTTP 抓取 =====
|
// ===== Phase 1: HTTP 抓取 =====
|
||||||
@@ -71,6 +81,8 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
return this.buildSuccess(url, browserResult, 'browser');
|
return this.buildSuccess(url, browserResult, 'browser');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 写入缓存
|
||||||
|
fetchCache.set(url, phase1Result.text);
|
||||||
return this.buildSuccess(url, phase1Result.text, 'http');
|
return this.buildSuccess(url, phase1Result.text, 'http');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +156,7 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
|
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Phase 2/3: 浏览器回退 =====
|
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
|
||||||
|
|
||||||
private async browserFetch(url: string): Promise<string | null> {
|
private async browserFetch(url: string): Promise<string | null> {
|
||||||
// 查缓存
|
// 查缓存
|
||||||
@@ -154,59 +166,50 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
let win: BrowserWindow | null = null;
|
|
||||||
let cleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
win = new BrowserWindow({
|
const manager = getBrowserManager();
|
||||||
width: 1280,
|
|
||||||
height: 800,
|
|
||||||
show: false,
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: true,
|
|
||||||
webSecurity: false, // 允许跨域(截图需要)
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cleanup = () => {
|
// 通过 manager 打开 URL(复用已打开的同 URL 窗口,避免重复创建)
|
||||||
try {
|
await manager.open({ url });
|
||||||
if (win && !win.isDestroyed()) win.close();
|
|
||||||
} catch {
|
|
||||||
// 忽略关闭错误
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.loadURLWithTimeout(win, url, 30_000);
|
|
||||||
|
|
||||||
// 等待 JS 渲染
|
// 等待 JS 渲染
|
||||||
await this.sleep(2_500);
|
await this.sleep(2_500);
|
||||||
|
|
||||||
// 提取页面正文
|
// 提取页面正文
|
||||||
const text = await win.webContents.executeJavaScript(`
|
const text = await manager.evaluate(`
|
||||||
(function() {
|
(function() {
|
||||||
var clone = document.body.cloneNode(true);
|
var clone = document.body.cloneNode(true);
|
||||||
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
|
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
|
||||||
noise.forEach(function(el) { el.remove(); });
|
noise.forEach(function(el) { el.remove(); });
|
||||||
return clone.innerText || '';
|
return clone.innerText || '';
|
||||||
})();
|
})();
|
||||||
`, true);
|
`) as string;
|
||||||
|
|
||||||
if (text && text.trim().length >= 80) {
|
if (text && text.trim().length >= 80) {
|
||||||
|
// 拦截检测(浏览器渲染后仍可能是验证码挑战页)
|
||||||
|
if (isInterceptedPage(text)) {
|
||||||
|
logTool('web_fetch', `Browser fetch detected intercepted page: ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容大小限制(与 HTTP 阶段一致,防止超大页面耗尽上下文)
|
||||||
|
const MAX_BROWSER_TEXT = 500_000; // 500K chars
|
||||||
|
const safeText = text.length > MAX_BROWSER_TEXT
|
||||||
|
? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]'
|
||||||
|
: text;
|
||||||
|
|
||||||
// 写缓存
|
// 写缓存
|
||||||
fetchCache.set(url, text);
|
fetchCache.set(url, safeText);
|
||||||
logTool('web_fetch', `Browser fetch success: ${text.length} chars`);
|
logTool('web_fetch', `Browser fetch success: ${safeText.length} chars`);
|
||||||
return text;
|
return safeText;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTool('web_fetch', `Browser fetch failed: ${(err as Error).message}`);
|
logTool('web_fetch', `Browser fetch failed: ${(err as Error).message}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
if (cleanup) cleanup();
|
|
||||||
}
|
}
|
||||||
|
// 注意:不关闭窗口 — manager 是单例,窗口由 web_browser 或 cleanupBrowser 管理
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 辅助方法 =====
|
// ===== 辅助方法 =====
|
||||||
@@ -224,17 +227,4 @@ export class WebFetchTool implements IMetonaTool {
|
|||||||
private sleep(ms: number): Promise<void> {
|
private sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 带超时的 loadURL(Electron 原生不支持 timeout 选项) */
|
|
||||||
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
|
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await Promise.race([win.loadURL(url), timeoutPromise]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
buildSearXNGAuthHeaders,
|
buildSearXNGAuthHeaders,
|
||||||
logTool,
|
logTool,
|
||||||
} from './network-utils';
|
} from './network-utils';
|
||||||
|
import type { WebFetchTool } from './web-fetch';
|
||||||
|
|
||||||
// ===== 类型定义 =====
|
// ===== 类型定义 =====
|
||||||
|
|
||||||
@@ -240,7 +241,10 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
timeoutMs: 300_000,
|
timeoutMs: 300_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {}
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private webFetchTool: WebFetchTool,
|
||||||
|
) {}
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||||||
const query = args.query as string;
|
const query = args.query as string;
|
||||||
@@ -463,7 +467,7 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
return Array.from(seen.values());
|
return Array.from(seen.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 摘要增强 =====
|
// ===== 摘要增强(委托给 WebFetchTool) =====
|
||||||
|
|
||||||
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> {
|
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> {
|
||||||
let enhanced = 0;
|
let enhanced = 0;
|
||||||
@@ -471,14 +475,13 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
if (enhanced >= maxEnhance) break;
|
if (enhanced >= maxEnhance) break;
|
||||||
if (r.snippet.length < 30 && r.reachable) {
|
if (r.snippet.length < 30 && r.reachable) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithTimeout(r.url, {
|
const fetchResult = await this.webFetchTool.execute(
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
|
{ url: r.url },
|
||||||
}, 5_000);
|
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
||||||
if (resp.ok) {
|
) as { success: boolean; content?: string };
|
||||||
const html = await resp.text();
|
|
||||||
// 延迟导入 htmlToText 以避免循环依赖
|
if (fetchResult.success && fetchResult.content) {
|
||||||
const { htmlToText } = await import('./network-utils');
|
const text = fetchResult.content.slice(0, 200);
|
||||||
const text = htmlToText(html).slice(0, 200);
|
|
||||||
if (text.length > r.snippet.length) {
|
if (text.length > r.snippet.length) {
|
||||||
r.snippet = text;
|
r.snippet = text;
|
||||||
r._enhanced = true;
|
r._enhanced = true;
|
||||||
@@ -492,7 +495,7 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 自动抓取完整内容 =====
|
// ===== 自动抓取完整内容(委托给 WebFetchTool) =====
|
||||||
|
|
||||||
private async autoFetch(
|
private async autoFetch(
|
||||||
query: string,
|
query: string,
|
||||||
@@ -524,21 +527,17 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetched: Array<{ url: string; title: string; content: string }> = [];
|
const fetched: Array<{ url: string; title: string; content: string }> = [];
|
||||||
const { htmlToText } = await import('./network-utils');
|
|
||||||
|
|
||||||
for (const item of toFetch) {
|
for (const item of toFetch) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithTimeout(item.result.url, {
|
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
|
||||||
headers: {
|
const fetchResult = await this.webFetchTool.execute(
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
|
{ url: item.result.url },
|
||||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
||||||
},
|
) as { success: boolean; content?: string; method?: string };
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
if (resp.ok) {
|
if (fetchResult.success && fetchResult.content) {
|
||||||
const html = await resp.text();
|
fetched.push({ url: item.result.url, title: item.result.title, content: fetchResult.content });
|
||||||
const content = htmlToText(html);
|
|
||||||
fetched.push({ url: item.result.url, title: item.result.title, content });
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
|
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
|
||||||
@@ -547,12 +546,13 @@ export class WebSearchTool implements IMetonaTool {
|
|||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
const randomPick = remaining[Math.floor(Math.random() * remaining.length)];
|
const randomPick = remaining[Math.floor(Math.random() * remaining.length)];
|
||||||
try {
|
try {
|
||||||
const resp2 = await fetchWithTimeout(randomPick.result.url, {
|
const fetchResult2 = await this.webFetchTool.execute(
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
|
{ url: randomPick.result.url },
|
||||||
}, 10_000);
|
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
|
||||||
if (resp2.ok) {
|
) as { success: boolean; content?: string };
|
||||||
const html2 = await resp2.text();
|
|
||||||
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: htmlToText(html2) });
|
if (fetchResult2.success && fetchResult2.content) {
|
||||||
|
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: fetchResult2.content });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略补充失败
|
// 忽略补充失败
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Tool Registry — 工具注册表
|
* Tool Registry — 工具注册表
|
||||||
*
|
*
|
||||||
* 管理所有可用工具(内置 + MCP),提供查找、注册、注销功能。
|
* 管理所有可用工具(内置 + MCP),提供查找、注册、注销功能。
|
||||||
|
* 提供 per-tool 超时强制和结果大小限制,防止卡死和上下文溢出。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +12,9 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../types/metona-tool';
|
import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../types/metona-tool';
|
||||||
|
|
||||||
|
/** 工具返回值最大字符数(约 50KB),超过则截断 */
|
||||||
|
const MAX_RESULT_CHARS = 50_000;
|
||||||
|
|
||||||
export class ToolRegistry {
|
export class ToolRegistry {
|
||||||
private tools = new Map<string, ToolRegistryEntry>();
|
private tools = new Map<string, ToolRegistryEntry>();
|
||||||
private disabledTools = new Set<string>();
|
private disabledTools = new Set<string>();
|
||||||
@@ -75,7 +79,7 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行工具 */
|
/** 执行工具(带超时强制和结果大小限制) */
|
||||||
async execute(
|
async execute(
|
||||||
toolCall: MetonaToolCall,
|
toolCall: MetonaToolCall,
|
||||||
context: ToolExecutionContext,
|
context: ToolExecutionContext,
|
||||||
@@ -94,12 +98,27 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startTs = Date.now();
|
const startTs = Date.now();
|
||||||
|
const timeoutMs = tool.definition.timeoutMs;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tool.execute(toolCall.args, context);
|
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
|
||||||
|
const result = await Promise.race([
|
||||||
|
tool.execute(toolCall.args, context),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 结果大小限制 — 防止过大返回值耗尽 LLM 上下文窗口
|
||||||
|
const safeResult = this.truncateResult(result);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toolCallId: toolCall.id,
|
toolCallId: toolCall.id,
|
||||||
toolName: toolCall.name,
|
toolName: toolCall.name,
|
||||||
result,
|
result: safeResult,
|
||||||
success: true,
|
success: true,
|
||||||
durationMs: Date.now() - startTs,
|
durationMs: Date.now() - startTs,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -117,6 +136,19 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 截断过大的工具返回值,防止 LLM 上下文溢出 */
|
||||||
|
private truncateResult(result: unknown): unknown {
|
||||||
|
const str = typeof result === 'string' ? result : JSON.stringify(result);
|
||||||
|
if (str.length <= MAX_RESULT_CHARS) return result;
|
||||||
|
|
||||||
|
return {
|
||||||
|
_truncated: true,
|
||||||
|
_original_size: str.length,
|
||||||
|
_preview: str.slice(0, MAX_RESULT_CHARS),
|
||||||
|
_message: `Result truncated: original ${str.length} chars exceeds limit ${MAX_RESULT_CHARS}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取工具数量 */
|
/** 获取工具数量 */
|
||||||
get size(): number {
|
get size(): number {
|
||||||
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
|
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
|
||||||
|
|||||||
@@ -85,6 +85,23 @@ export function registerAllIPCHandlers(
|
|||||||
const workspaceFiles = workspaceService.getFiles();
|
const workspaceFiles = workspaceService.getFiles();
|
||||||
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles);
|
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles);
|
||||||
|
|
||||||
|
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
|
||||||
|
try {
|
||||||
|
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
|
||||||
|
if (memories.length > 0) {
|
||||||
|
const memorySection = memories.map((m, i) =>
|
||||||
|
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
|
||||||
|
).join('\n');
|
||||||
|
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
|
||||||
|
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
|
||||||
|
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
|
||||||
|
: memoryBlock;
|
||||||
|
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// 监听 Agent Loop 事件
|
// 监听 Agent Loop 事件
|
||||||
const onStreamEvent = (event: MetonaStreamEvent) => {
|
const onStreamEvent = (event: MetonaStreamEvent) => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
@@ -502,7 +519,6 @@ export function registerAllIPCHandlers(
|
|||||||
|
|
||||||
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
|
||||||
try {
|
try {
|
||||||
const db = sessionService.getDB();
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// 导出单个会话
|
// 导出单个会话
|
||||||
const messages = sessionService.getMessages(sessionId);
|
const messages = sessionService.getMessages(sessionId);
|
||||||
@@ -524,8 +540,8 @@ export function registerAllIPCHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('data:clearSessions', async () => {
|
ipcMain.handle('data:clearSessions', async () => {
|
||||||
try {
|
|
||||||
const db = sessionService.getDB();
|
const db = sessionService.getDB();
|
||||||
|
try {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
db.exec('DELETE FROM messages');
|
db.exec('DELETE FROM messages');
|
||||||
db.exec('DELETE FROM sessions');
|
db.exec('DELETE FROM sessions');
|
||||||
@@ -533,6 +549,7 @@ export function registerAllIPCHandlers(
|
|||||||
log.info('[DATA] All sessions cleared');
|
log.info('[DATA] All sessions cleared');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -551,9 +568,9 @@ export function registerAllIPCHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('data:clearAuditLogs', async () => {
|
ipcMain.handle('data:clearAuditLogs', async () => {
|
||||||
try {
|
|
||||||
// 审计日志是 INSERT-ONLY,需要先禁用触发器
|
// 审计日志是 INSERT-ONLY,需要先禁用触发器
|
||||||
const db = sessionService.getDB();
|
const db = sessionService.getDB();
|
||||||
|
try {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
|
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
|
||||||
db.exec('DELETE FROM audit_logs');
|
db.exec('DELETE FROM audit_logs');
|
||||||
@@ -567,6 +584,7 @@ export function registerAllIPCHandlers(
|
|||||||
log.info('[DATA] Audit logs cleared');
|
log.info('[DATA] Audit logs cleared');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-8
@@ -41,13 +41,15 @@ import {
|
|||||||
WebSearchTool, WebFetchTool,
|
WebSearchTool, WebFetchTool,
|
||||||
MemoryStoreTool, MemorySearchTool,
|
MemoryStoreTool, MemorySearchTool,
|
||||||
RunCommandTool,
|
RunCommandTool,
|
||||||
browserTools, cleanupBrowser,
|
WebBrowserTool, cleanupBrowser,
|
||||||
|
DelegateTaskTool,
|
||||||
} from './harness/tools/built-in';
|
} from './harness/tools/built-in';
|
||||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||||
import { PolicyEngine } from './harness/sandbox/permissions';
|
import { PolicyEngine } from './harness/sandbox/permissions';
|
||||||
import { SandboxManager } from './harness/sandbox/sandbox';
|
import { SandboxManager } from './harness/sandbox/sandbox';
|
||||||
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
||||||
import { OutputValidator } from './harness/verification/output-validator';
|
import { OutputValidator } from './harness/verification/output-validator';
|
||||||
|
import { TaskOrchestrator } from './harness/orchestration/orchestrator';
|
||||||
import { UpdateService } from './services/update.service';
|
import { UpdateService } from './services/update.service';
|
||||||
|
|
||||||
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
|
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
|
||||||
@@ -148,22 +150,24 @@ async function initialize(): Promise<void> {
|
|||||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||||
const contextBuilder = new ContextBuilder();
|
const contextBuilder = new ContextBuilder();
|
||||||
|
|
||||||
// ===== 步骤 6: 注册 9 个内置工具 =====
|
// ===== 步骤 6: 注册内置工具 =====
|
||||||
const toolRegistry = new ToolRegistry();
|
const toolRegistry = new ToolRegistry();
|
||||||
toolRegistry.registerBuiltin(new ReadFileTool());
|
toolRegistry.registerBuiltin(new ReadFileTool());
|
||||||
toolRegistry.registerBuiltin(new WriteFileTool());
|
toolRegistry.registerBuiltin(new WriteFileTool());
|
||||||
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
||||||
toolRegistry.registerBuiltin(new SearchFilesTool());
|
toolRegistry.registerBuiltin(new SearchFilesTool());
|
||||||
toolRegistry.registerBuiltin(new WebSearchTool(configService));
|
|
||||||
toolRegistry.registerBuiltin(new WebFetchTool());
|
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
|
||||||
|
const webFetchTool = new WebFetchTool();
|
||||||
|
toolRegistry.registerBuiltin(webFetchTool);
|
||||||
|
toolRegistry.registerBuiltin(new WebSearchTool(configService, webFetchTool));
|
||||||
|
|
||||||
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
||||||
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
||||||
toolRegistry.registerBuiltin(new RunCommandTool());
|
toolRegistry.registerBuiltin(new RunCommandTool());
|
||||||
|
|
||||||
// 注册 9 个 Browser 浏览器工具
|
// 注册 Web Browser 统一浏览器工具
|
||||||
for (const tool of browserTools) {
|
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||||
toolRegistry.registerBuiltin(tool);
|
|
||||||
}
|
|
||||||
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
log.info(`Registered ${toolRegistry.size} built-in tools`);
|
||||||
|
|
||||||
// ===== MCP Manager =====
|
// ===== MCP Manager =====
|
||||||
@@ -211,6 +215,19 @@ async function initialize(): Promise<void> {
|
|||||||
agentLoop.setTools(toolRegistry.listTools());
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||||
|
|
||||||
|
// ===== Task Orchestrator(子任务委派)=====
|
||||||
|
const orchestrator = new TaskOrchestrator(
|
||||||
|
agentLoop, toolRegistry, preToolHooks, postToolHooks,
|
||||||
|
{
|
||||||
|
thinkingEnabled: agentThinkingEnabled ?? true,
|
||||||
|
thinkingEffort: agentThinkingEffort ?? 'high',
|
||||||
|
contextLength: ollamaNumCtx ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||||||
|
// 重新设置工具列表,包含新注册的 delegate_task
|
||||||
|
agentLoop.setTools(toolRegistry.listTools());
|
||||||
|
|
||||||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||||||
const reloadAdapter = () => {
|
const reloadAdapter = () => {
|
||||||
const newAdapter = createAdapter();
|
const newAdapter = createAdapter();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const metonaAPI = {
|
|||||||
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
|
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
|
||||||
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
|
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
|
||||||
pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned),
|
pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned),
|
||||||
|
archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived),
|
||||||
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
|
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
|
||||||
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
|
||||||
saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
|
saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function TraceViewer(): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
traceSteps.map((step, i) => (
|
traceSteps.map((step, i) => (
|
||||||
<TraceStep
|
<TraceStep
|
||||||
key={`${step.iteration}-${step.state}-${i}`}
|
key={step.id}
|
||||||
step={step}
|
step={step}
|
||||||
isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'}
|
isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+135
-67
@@ -1,13 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* useAgentStream — Agent 流式事件监听 Hook
|
* useAgentStream — Agent 流式事件监听 Hook
|
||||||
*
|
*
|
||||||
* 监听 window.metona.agent.onStreamEvent,将事件分发到 Zustand Store。
|
* 监听 window.metona.agent.onStreamEvent / onStateChange,将事件分发到 Zustand Store。
|
||||||
* 负责流式文本追加、工具调用状态更新、思考内容处理。
|
*
|
||||||
|
* 架构设计:
|
||||||
|
* - onStateChange:迭代号追踪 + 消息卡片创建 + Trace 步骤创建 + Agent 状态映射
|
||||||
|
* (状态变化事件总是先于同迭代的流式内容事件到达,因此在此统一管理迭代边界)
|
||||||
|
* - onStreamEvent:纯内容更新(reasoning、text、tool_call、tool_result、usage、done、error)
|
||||||
|
* (不再处理迭代号比较,消除竞态条件)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAgentStore, type ToolCallInfo } from '@renderer/stores/agent-store';
|
import { useAgentStore, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
|
||||||
import { useSessionStore } from '@renderer/stores/session-store';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 流式事件监听 Hook
|
* Agent 流式事件监听 Hook
|
||||||
@@ -17,8 +21,8 @@ import { useSessionStore } from '@renderer/stores/session-store';
|
|||||||
export function useAgentStream(): void {
|
export function useAgentStream(): void {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null);
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// ===== 流式内容事件 =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检查 IPC 桥是否可用
|
|
||||||
if (!window.metona?.agent?.onStreamEvent) return;
|
if (!window.metona?.agent?.onStreamEvent) return;
|
||||||
|
|
||||||
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
|
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
|
||||||
@@ -39,39 +43,7 @@ export function useAgentStream(): void {
|
|||||||
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
|
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
|
||||||
const getStore = () => useAgentStore.getState();
|
const getStore = () => useAgentStore.getState();
|
||||||
|
|
||||||
// 每次收到迭代号时同步更新
|
|
||||||
if (data.iteration != null && data.iteration !== getStore().currentIteration) {
|
|
||||||
const prevIteration = getStore().currentIteration;
|
|
||||||
getStore().setCurrentIteration(data.iteration);
|
|
||||||
|
|
||||||
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
|
|
||||||
if (data.iteration > prevIteration && prevIteration > 0) {
|
|
||||||
const messages = getStore().messages;
|
|
||||||
const lastMsg = messages[messages.length - 1];
|
|
||||||
// 仅当上一条 assistant 消息已有内容时才创建新消息(避免空消息堆叠)
|
|
||||||
if (lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)) {
|
|
||||||
getStore().addMessage({
|
|
||||||
id: `msg_${Date.now()}_assistant`,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
iteration: data.iteration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
// 思考开始
|
|
||||||
case 'thinking_start':
|
|
||||||
getStore().setAgentStatus('thinking');
|
|
||||||
getStore().addTraceStep({
|
|
||||||
iteration: data.iteration ?? getStore().currentIteration + 1,
|
|
||||||
state: 'THINKING',
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// 推理内容增量
|
// 推理内容增量
|
||||||
case 'reasoning_delta':
|
case 'reasoning_delta':
|
||||||
if (data.delta) {
|
if (data.delta) {
|
||||||
@@ -90,16 +62,18 @@ export function useAgentStream(): void {
|
|||||||
content: '',
|
content: '',
|
||||||
reasoningContent: data.delta,
|
reasoningContent: data.delta,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
iteration: data.iteration ?? getStore().currentIteration,
|
iteration: getStore().currentIteration || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// 思考结束
|
// 同步更新当前 Trace 步骤的 thought 字段
|
||||||
case 'thinking_end':
|
const traceSteps = getStore().traceSteps;
|
||||||
if (data.iteration) {
|
const curStep = traceSteps[traceSteps.length - 1];
|
||||||
getStore().updateTraceStep(data.iteration, { completedAt: Date.now() });
|
if (curStep) {
|
||||||
|
getStore().updateLastTraceStep({
|
||||||
|
thought: (curStep.thought ?? '') + data.delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -108,6 +82,18 @@ export function useAgentStream(): void {
|
|||||||
if (data.delta) {
|
if (data.delta) {
|
||||||
getStore().setStreaming(true);
|
getStore().setStreaming(true);
|
||||||
getStore().updateLastAssistantMessage(data.delta);
|
getStore().updateLastAssistantMessage(data.delta);
|
||||||
|
|
||||||
|
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
|
||||||
|
// 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought
|
||||||
|
const messages = getStore().messages;
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
const steps = getStore().traceSteps;
|
||||||
|
const step = steps[steps.length - 1];
|
||||||
|
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
|
||||||
|
getStore().updateLastTraceStep({
|
||||||
|
thought: (step.thought ?? '') + data.delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -118,7 +104,7 @@ export function useAgentStream(): void {
|
|||||||
id: data.toolCall.id,
|
id: data.toolCall.id,
|
||||||
name: data.toolCall.name,
|
name: data.toolCall.name,
|
||||||
args: data.toolCall.args,
|
args: data.toolCall.args,
|
||||||
status: 'pending',
|
status: 'executing',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 追加到当前 assistant 消息
|
// 追加到当前 assistant 消息
|
||||||
@@ -130,15 +116,15 @@ export function useAgentStream(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getStore().setAgentStatus('executing');
|
// 更新当前 Trace 步骤(添加工具调用信息)
|
||||||
const curIter = getStore().currentIteration;
|
const curSteps = getStore().traceSteps;
|
||||||
getStore().addTraceStep({
|
const lastStep = curSteps[curSteps.length - 1];
|
||||||
iteration: data.iteration ?? curIter,
|
if (lastStep) {
|
||||||
state: 'EXECUTING',
|
getStore().updateLastTraceStep({
|
||||||
startedAt: Date.now(),
|
toolCalls: [...(lastStep.toolCalls ?? []), tc],
|
||||||
toolCalls: [tc],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// 工具执行结果
|
// 工具执行结果
|
||||||
@@ -160,6 +146,24 @@ export function useAgentStream(): void {
|
|||||||
);
|
);
|
||||||
getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
|
getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步更新 Trace 步骤中的工具调用状态
|
||||||
|
const steps = getStore().traceSteps;
|
||||||
|
const lastTrace = steps[steps.length - 1];
|
||||||
|
if (lastTrace?.toolCalls) {
|
||||||
|
const updatedTraceToolCalls = lastTrace.toolCalls.map((tc) =>
|
||||||
|
tc.id === data.toolResult!.toolCallId
|
||||||
|
? {
|
||||||
|
...tc,
|
||||||
|
status: data.toolResult!.success ? 'success' as const : 'error' as const,
|
||||||
|
result: data.toolResult!.result,
|
||||||
|
error: data.toolResult!.error,
|
||||||
|
durationMs: data.toolResult!.durationMs,
|
||||||
|
}
|
||||||
|
: tc,
|
||||||
|
);
|
||||||
|
getStore().updateLastTraceStep({ toolCalls: updatedTraceToolCalls });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,15 @@ export function useAgentStream(): void {
|
|||||||
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
|
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
|
||||||
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
|
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 同步更新当前 Trace 步骤的 token 用量
|
||||||
|
getStore().updateLastTraceStep({
|
||||||
|
tokenUsage: {
|
||||||
|
promptTokens: data.usage.inputTokens ?? 0,
|
||||||
|
completionTokens: data.usage.outputTokens ?? 0,
|
||||||
|
totalTokens: data.usage.totalTokens ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -180,6 +193,8 @@ export function useAgentStream(): void {
|
|||||||
case 'done':
|
case 'done':
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
getStore().setAgentStatus('idle');
|
getStore().setAgentStatus('idle');
|
||||||
|
// 标记最后一个 Trace 步骤为已完成
|
||||||
|
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
||||||
getStore().saveTraceData();
|
getStore().saveTraceData();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -187,6 +202,7 @@ export function useAgentStream(): void {
|
|||||||
case 'error':
|
case 'error':
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
getStore().setAgentStatus('error');
|
getStore().setAgentStatus('error');
|
||||||
|
getStore().updateLastTraceStep({ completedAt: Date.now() });
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: `msg_${Date.now()}_error`,
|
id: `msg_${Date.now()}_error`,
|
||||||
role: 'system',
|
role: 'system',
|
||||||
@@ -194,17 +210,6 @@ export function useAgentStream(): void {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// 状态变化
|
|
||||||
case 'state_change':
|
|
||||||
if (data.state) {
|
|
||||||
getStore().addTraceStep({
|
|
||||||
iteration: data.iteration ?? getStore().currentIteration,
|
|
||||||
state: data.state,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,16 +220,79 @@ export function useAgentStream(): void {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 监听状态变化事件(含迭代号)
|
// ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.metona?.agent?.onStateChange) return;
|
if (!window.metona?.agent?.onStateChange) return;
|
||||||
|
|
||||||
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
|
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
|
||||||
const data = state as { sessionId?: string; iteration?: number; state?: string; previous?: string; current?: string };
|
const data = state as {
|
||||||
if (data.iteration != null) {
|
sessionId?: string;
|
||||||
|
iteration?: number;
|
||||||
|
state?: string;
|
||||||
|
previous?: string;
|
||||||
|
current?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const store = useAgentStore.getState();
|
const store = useAgentStore.getState();
|
||||||
if (data.iteration !== store.currentIteration) {
|
|
||||||
|
// --- 迭代号更新 + 新消息卡片创建 ---
|
||||||
|
if (data.iteration != null && data.iteration !== store.currentIteration) {
|
||||||
|
const prevIteration = store.currentIteration;
|
||||||
store.setCurrentIteration(data.iteration);
|
store.setCurrentIteration(data.iteration);
|
||||||
|
|
||||||
|
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
|
||||||
|
// 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时
|
||||||
|
if (data.iteration > prevIteration && prevIteration > 0) {
|
||||||
|
const messages = store.messages;
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
if (
|
||||||
|
lastMsg?.role === 'assistant' &&
|
||||||
|
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)
|
||||||
|
) {
|
||||||
|
store.addMessage({
|
||||||
|
id: `msg_${Date.now()}_assistant`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
iteration: data.iteration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Trace 步骤管理 ---
|
||||||
|
if (data.state && data.iteration != null) {
|
||||||
|
const traceSteps = store.traceSteps;
|
||||||
|
|
||||||
|
// 标记上一个 Trace 步骤为已完成
|
||||||
|
if (traceSteps.length > 0) {
|
||||||
|
const lastStep = traceSteps[traceSteps.length - 1];
|
||||||
|
if (!lastStep.completedAt) {
|
||||||
|
store.updateLastTraceStep({ completedAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 Trace 步骤
|
||||||
|
store.addTraceStep({
|
||||||
|
id: `trace_${data.iteration}_${data.state}_${Date.now()}`,
|
||||||
|
iteration: data.iteration,
|
||||||
|
state: data.state,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Agent 状态映射 ---
|
||||||
|
const stateToStatus: Record<string, AgentStatus> = {
|
||||||
|
THINKING: 'thinking',
|
||||||
|
EXECUTING: 'executing',
|
||||||
|
PARSING: 'thinking',
|
||||||
|
OBSERVING: 'thinking',
|
||||||
|
COMPRESSING: 'thinking',
|
||||||
|
INIT: 'thinking',
|
||||||
|
TERMINATED: 'idle',
|
||||||
|
};
|
||||||
|
const newStatus = stateToStatus[data.state];
|
||||||
|
if (newStatus) {
|
||||||
|
store.setAgentStatus(newStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface TokenUsage {
|
|||||||
// ===== Trace 步骤 =====
|
// ===== Trace 步骤 =====
|
||||||
|
|
||||||
export interface TraceStep {
|
export interface TraceStep {
|
||||||
|
id: string;
|
||||||
iteration: number;
|
iteration: number;
|
||||||
state: string;
|
state: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
@@ -103,6 +104,7 @@ interface AgentState {
|
|||||||
clearStreamingContent: () => void;
|
clearStreamingContent: () => void;
|
||||||
addTraceStep: (step: TraceStep) => void;
|
addTraceStep: (step: TraceStep) => void;
|
||||||
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
|
||||||
|
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
|
||||||
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
|
||||||
setProvider: (provider: string, model: string) => void;
|
setProvider: (provider: string, model: string) => void;
|
||||||
setMaxIterations: (max: number) => void;
|
setMaxIterations: (max: number) => void;
|
||||||
@@ -160,7 +162,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
if (id && window.metona?.sessions?.getTrace) {
|
if (id && window.metona?.sessions?.getTrace) {
|
||||||
window.metona.sessions.getTrace(id).then((data) => {
|
window.metona.sessions.getTrace(id).then((data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
|
if (data.traceSteps) {
|
||||||
|
// 兼容旧数据:为缺少 id 的 trace 步骤生成 id
|
||||||
|
const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({
|
||||||
|
...t,
|
||||||
|
id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`,
|
||||||
|
}));
|
||||||
|
set({ traceSteps: steps });
|
||||||
|
}
|
||||||
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
|
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
|
||||||
}
|
}
|
||||||
}).catch((err) => { console.error('[AgentStore]', err); });
|
}).catch((err) => { console.error('[AgentStore]', err); });
|
||||||
@@ -321,6 +330,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
updateLastTraceStep: (updates) =>
|
||||||
|
set((s) => {
|
||||||
|
if (s.traceSteps.length === 0) return s;
|
||||||
|
const steps = [...s.traceSteps];
|
||||||
|
steps[steps.length - 1] = { ...steps[steps.length - 1], ...updates };
|
||||||
|
return { traceSteps: steps };
|
||||||
|
}),
|
||||||
|
|
||||||
updateTokenUsage: (usage) =>
|
updateTokenUsage: (usage) =>
|
||||||
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user