bac32fe8f6
- 扩展 SessionStats schema 添加 token 统计字段 - 添加 TokenUsageInfo 类型和 ChatResult.usage 字段 - AgentMessageHandler 从 AI SDK response 提取 usage - AgentExecutor 返回 usage 到执行结果 - 新增 TokenStatsManager 管理统计: - updateSessionStats: 更新会话 token 统计 - mergeChildSessionStats: 合并子会话统计到父会话 - getSessionStats/getProjectStats: 查询统计 - Agent.chat() 完成后自动更新统计 - Task 工具完成后合并子会话统计 - 新增 REST API: /api/stats/sessions/:id, /api/stats/projects/:id - 添加 TokenStatsManager 单元测试 (12 tests)
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import {
|
|
generateText,
|
|
streamText,
|
|
stepCountIs,
|
|
type ModelMessage,
|
|
type Tool as AITool,
|
|
type LanguageModel,
|
|
} from 'ai';
|
|
import type { Tool, ToolResult, AgentConfig, ContentBlock, TokenUsageInfo } from '../types/index.js';
|
|
import { buildZodSchema } from '../types/index.js';
|
|
import { ToolRegistry } from '../tools/registry.js';
|
|
import type {
|
|
AgentInfo,
|
|
AgentExecutionContext,
|
|
AgentExecutionResult,
|
|
ImageData,
|
|
} from './types.js';
|
|
import { checkBashPermission, isPathInAllowedWritePaths } from './permission-merger.js';
|
|
import { getProviderRegistry, resolveApiKey } from '../provider/index.js';
|
|
import { renderPromptTemplate, createPlanContext } from '../template/index.js';
|
|
import { agentEventEmitter } from './events.js';
|
|
|
|
/**
|
|
* Agent 执行器
|
|
* 根据 Agent 配置执行任务,支持工具过滤和权限控制
|
|
*/
|
|
export class AgentExecutor {
|
|
private agentInfo: AgentInfo;
|
|
private baseConfig: AgentConfig;
|
|
private toolRegistry: ToolRegistry;
|
|
private getModel: (model: string) => LanguageModel;
|
|
|
|
constructor(
|
|
agentInfo: AgentInfo,
|
|
baseConfig: AgentConfig,
|
|
toolRegistry: ToolRegistry
|
|
) {
|
|
this.agentInfo = agentInfo;
|
|
this.baseConfig = baseConfig;
|
|
this.toolRegistry = toolRegistry;
|
|
|
|
// 使用 ProviderRegistry 获取模型工厂
|
|
const provider = agentInfo.model?.provider ?? baseConfig.provider;
|
|
const registry = getProviderRegistry();
|
|
|
|
// 当 Agent 指定了不同的 provider 时,需要从 ProviderRegistry 获取对应的配置
|
|
// 而不是使用 baseConfig(主 Agent 配置)的 apiKey 和 baseUrl
|
|
let apiKey = baseConfig.apiKey;
|
|
let baseUrl = baseConfig.baseUrl;
|
|
|
|
if (agentInfo.model?.provider && agentInfo.model.provider !== baseConfig.provider) {
|
|
// Agent 使用了不同的 provider,获取对应 provider 的配置
|
|
const providerConfig = registry.getConfig(provider);
|
|
apiKey = resolveApiKey(providerConfig) || baseConfig.apiKey;
|
|
baseUrl = providerConfig?.baseUrl;
|
|
}
|
|
|
|
this.getModel = registry.getModelFactory(provider, { apiKey, baseUrl });
|
|
}
|
|
|
|
/**
|
|
* 执行任务
|
|
*/
|
|
async execute(
|
|
prompt: string,
|
|
context: AgentExecutionContext
|
|
): Promise<AgentExecutionResult> {
|
|
const { onStream, onToolCall, onToolResult, images, sessionId, agentId, emitEvents } = context;
|
|
|
|
// 是否发射子 Agent 事件
|
|
const shouldEmitEvents = emitEvents && sessionId && agentId;
|
|
|
|
// 获取过滤后的工具
|
|
const tools = this.getFilteredTools();
|
|
const vercelTools = this.buildVercelTools(tools);
|
|
|
|
// 构建系统提示词
|
|
const systemPrompt = this.buildSystemPrompt();
|
|
|
|
// 获取模型配置
|
|
const modelName = this.agentInfo.model?.model ?? this.baseConfig.model;
|
|
const maxSteps = this.agentInfo.maxSteps ?? 10;
|
|
const maxTokens = this.agentInfo.model?.maxTokens ?? this.baseConfig.maxTokens;
|
|
|
|
// 构建消息内容(支持图片)
|
|
const messageContent = this.buildMessageContent(prompt, images);
|
|
|
|
// 构建初始消息
|
|
const messages: ModelMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: messageContent,
|
|
},
|
|
];
|
|
|
|
let fullResponse = '';
|
|
let steps = 0;
|
|
let usage: TokenUsageInfo | undefined;
|
|
|
|
// 工具调用时间追踪(用于计算持续时间)
|
|
const toolStartTimes = new Map<string, number>();
|
|
|
|
try {
|
|
if (onStream || shouldEmitEvents) {
|
|
// 流式模式(或需要发射事件时使用流式模式)
|
|
const result = streamText({
|
|
model: this.getModel(modelName),
|
|
system: systemPrompt,
|
|
messages,
|
|
tools: vercelTools,
|
|
maxOutputTokens: maxTokens,
|
|
stopWhen: stepCountIs(maxSteps),
|
|
onChunk: ({ chunk }) => {
|
|
if (chunk.type === 'tool-call') {
|
|
steps++;
|
|
const toolArgs = 'input' in chunk ? chunk.input : {};
|
|
const toolCallId = `tool-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
// 记录工具开始时间
|
|
toolStartTimes.set(chunk.toolName, Date.now());
|
|
|
|
onToolCall?.(chunk.toolName, toolArgs as Record<string, unknown>);
|
|
onStream?.(`\n[调用工具: ${chunk.toolName}]\n`);
|
|
|
|
// 发射子 Agent 工具开始事件
|
|
if (shouldEmitEvents) {
|
|
agentEventEmitter.emit({
|
|
type: 'subagent:tool_start',
|
|
sessionId: sessionId!,
|
|
agentId: agentId!,
|
|
toolCallId,
|
|
toolName: chunk.toolName,
|
|
args: toolArgs as Record<string, unknown>,
|
|
});
|
|
}
|
|
} else if (chunk.type === 'tool-result') {
|
|
const output = (chunk as { output?: ToolResult }).output;
|
|
const toolName = (chunk as { toolName?: string }).toolName ?? 'unknown';
|
|
const toolCallId = `tool-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
|
|
// 计算工具执行时间
|
|
const startTime = toolStartTimes.get(toolName);
|
|
const duration = startTime ? Date.now() - startTime : undefined;
|
|
toolStartTimes.delete(toolName);
|
|
|
|
onToolResult?.(toolName, output);
|
|
|
|
if (output && typeof output === 'object') {
|
|
if (output.success) {
|
|
const displayOutput =
|
|
output.output.length > 500
|
|
? output.output.substring(0, 500) + '...(截断)'
|
|
: output.output;
|
|
onStream?.(`[结果: ${displayOutput}]\n`);
|
|
|
|
// 发射子 Agent 工具完成事件
|
|
if (shouldEmitEvents) {
|
|
agentEventEmitter.emit({
|
|
type: 'subagent:tool_end',
|
|
sessionId: sessionId!,
|
|
agentId: agentId!,
|
|
toolCallId,
|
|
status: 'completed',
|
|
result: output.output,
|
|
duration,
|
|
});
|
|
}
|
|
} else {
|
|
onStream?.(`[错误: ${output.error}]\n`);
|
|
|
|
// 发射子 Agent 工具错误事件
|
|
if (shouldEmitEvents) {
|
|
agentEventEmitter.emit({
|
|
type: 'subagent:tool_end',
|
|
sessionId: sessionId!,
|
|
agentId: agentId!,
|
|
toolCallId,
|
|
status: 'error',
|
|
error: output.error,
|
|
duration,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
for await (const chunk of result.textStream) {
|
|
fullResponse += chunk;
|
|
onStream?.(chunk);
|
|
|
|
// 发射子 Agent 流式输出事件
|
|
if (shouldEmitEvents && chunk) {
|
|
agentEventEmitter.emit({
|
|
type: 'subagent:stream',
|
|
sessionId: sessionId!,
|
|
agentId: agentId!,
|
|
content: chunk,
|
|
});
|
|
}
|
|
}
|
|
|
|
const response = await result.response;
|
|
// 提取 usage 信息
|
|
usage = this.extractUsage(response);
|
|
} else {
|
|
// 非流式模式
|
|
const result = await generateText({
|
|
model: this.getModel(modelName),
|
|
system: systemPrompt,
|
|
messages,
|
|
tools: vercelTools,
|
|
maxOutputTokens: maxTokens,
|
|
stopWhen: stepCountIs(maxSteps),
|
|
});
|
|
|
|
fullResponse = result.text;
|
|
steps = result.steps.length;
|
|
// 提取 usage 信息
|
|
usage = this.extractUsage(result.response);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
text: fullResponse,
|
|
steps,
|
|
sessionId: context.parentSessionId ?? 'standalone',
|
|
usage,
|
|
};
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
text: '',
|
|
steps,
|
|
sessionId: context.parentSessionId ?? 'standalone',
|
|
error: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取过滤后的工具列表
|
|
*/
|
|
private getFilteredTools(): Tool[] {
|
|
const allTools = this.toolRegistry.getAllTools();
|
|
const toolConfig = this.agentInfo.tools;
|
|
|
|
// 如果没有工具配置,返回所有工具
|
|
if (!toolConfig) {
|
|
return allTools;
|
|
}
|
|
|
|
let filteredTools = allTools;
|
|
|
|
// 如果指定了 enabled,只保留这些工具
|
|
if (toolConfig.enabled && toolConfig.enabled.length > 0) {
|
|
const enabledSet = new Set(toolConfig.enabled);
|
|
filteredTools = filteredTools.filter((t) => enabledSet.has(t.name));
|
|
}
|
|
|
|
// 移除 disabled 的工具
|
|
if (toolConfig.disabled && toolConfig.disabled.length > 0) {
|
|
const disabledSet = new Set(toolConfig.disabled);
|
|
filteredTools = filteredTools.filter((t) => !disabledSet.has(t.name));
|
|
}
|
|
|
|
// 如果禁止嵌套 Task,移除 task 工具
|
|
if (toolConfig.noTask) {
|
|
filteredTools = filteredTools.filter((t) => t.name !== 'task');
|
|
}
|
|
|
|
return filteredTools;
|
|
}
|
|
|
|
/**
|
|
* 构建 Vercel AI SDK 工具格式
|
|
*/
|
|
private buildVercelTools(tools: Tool[]): Record<string, AITool> {
|
|
const vercelTools: Record<string, AITool> = {};
|
|
|
|
for (const tool of tools) {
|
|
const schema = buildZodSchema(tool.parameters);
|
|
|
|
vercelTools[tool.name] = {
|
|
description: tool.description,
|
|
inputSchema: schema,
|
|
execute: async (params) => {
|
|
// 权限检查
|
|
const permissionResult = await this.checkToolPermission(
|
|
tool.name,
|
|
params as Record<string, unknown>
|
|
);
|
|
if (!permissionResult.allowed) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: `权限拒绝: ${permissionResult.reason}`,
|
|
};
|
|
}
|
|
|
|
return tool.execute(params as Record<string, unknown>);
|
|
},
|
|
} as AITool;
|
|
}
|
|
|
|
return vercelTools;
|
|
}
|
|
|
|
/**
|
|
* 检查工具调用权限
|
|
*/
|
|
private async checkToolPermission(
|
|
toolName: string,
|
|
params: Record<string, unknown>
|
|
): Promise<{ allowed: boolean; reason?: string }> {
|
|
const permission = this.agentInfo.permission;
|
|
if (!permission) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
// Bash 权限检查
|
|
if (toolName === 'bash' && permission.bash) {
|
|
const command = params.command as string;
|
|
if (!command) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
const action = checkBashPermission(command, permission.bash);
|
|
if (action === 'deny') {
|
|
return { allowed: false, reason: `命令被禁止: ${command}` };
|
|
}
|
|
// ask 在这里视为允许(实际的 ask 逻辑在权限管理器中处理)
|
|
}
|
|
|
|
// 文件写入权限检查
|
|
if (['write_file', 'edit_file'].includes(toolName)) {
|
|
const filePermission = permission.file;
|
|
if (filePermission) {
|
|
const operation = toolName === 'write_file' ? 'write' : 'edit';
|
|
const action = filePermission[operation];
|
|
if (action === 'deny') {
|
|
return { allowed: false, reason: `${operation} 操作被禁止` };
|
|
}
|
|
|
|
// 检查 allowedWritePaths 限制(仅对 write 操作)
|
|
if (operation === 'write' && filePermission.allowedWritePaths) {
|
|
const filePath = params.path as string;
|
|
if (filePath && !isPathInAllowedWritePaths(filePath, filePermission.allowedWritePaths)) {
|
|
return {
|
|
allowed: false,
|
|
reason: `写入路径不在允许列表中: ${filePath}。只能写入: ${filePermission.allowedWritePaths.join(', ')}`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Git 写操作权限检查
|
|
const gitWriteTools = ['git_add', 'git_commit', 'git_push', 'git_checkout', 'git_stash'];
|
|
if (gitWriteTools.includes(toolName) && permission.git?.write === 'deny') {
|
|
return { allowed: false, reason: 'Git 写操作被禁止' };
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
/**
|
|
* 构建系统提示词
|
|
* 如果 Agent 启用了 promptTemplate,则动态渲染模板变量
|
|
*/
|
|
private buildSystemPrompt(): string {
|
|
// 如果 Agent 有自定义 prompt
|
|
if (this.agentInfo.prompt) {
|
|
// 如果启用了模板渲染,动态解析变量
|
|
if (this.agentInfo.promptTemplate) {
|
|
const context = createPlanContext({
|
|
workdir: process.cwd(),
|
|
isSubagent: this.agentInfo.mode === 'subagent',
|
|
});
|
|
return renderPromptTemplate(this.agentInfo.prompt, context);
|
|
}
|
|
return this.agentInfo.prompt;
|
|
}
|
|
|
|
// 否则使用基础配置的 systemPrompt
|
|
return this.baseConfig.systemPrompt;
|
|
}
|
|
|
|
/**
|
|
* 构建消息内容(支持图片)
|
|
*/
|
|
private buildMessageContent(
|
|
prompt: string,
|
|
images?: ImageData[]
|
|
): string | ContentBlock[] {
|
|
// 如果没有图片,直接返回文本
|
|
if (!images || images.length === 0) {
|
|
return prompt;
|
|
}
|
|
|
|
// 构建多模态内容
|
|
const blocks: ContentBlock[] = [];
|
|
|
|
// 先添加图片
|
|
for (const img of images) {
|
|
blocks.push({
|
|
type: 'image',
|
|
image: img.data,
|
|
mimeType: img.mimeType,
|
|
});
|
|
}
|
|
|
|
// 再添加文本
|
|
if (prompt) {
|
|
blocks.push({
|
|
type: 'text',
|
|
text: prompt,
|
|
});
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* 从 AI SDK 响应中提取 usage 信息
|
|
*/
|
|
private extractUsage(response: unknown): TokenUsageInfo | undefined {
|
|
// AI SDK 的 response 对象包含 usage 字段
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const resp = response as any;
|
|
const usage = resp?.usage;
|
|
|
|
if (!usage) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
promptTokens: usage.promptTokens ?? 0,
|
|
completionTokens: usage.completionTokens ?? 0,
|
|
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
|
|
// Anthropic API 特有的缓存字段
|
|
cacheReadInputTokens: usage.cacheReadInputTokens,
|
|
cacheCreationInputTokens: usage.cacheCreationInputTokens,
|
|
};
|
|
}
|
|
}
|