mirror of
https://gitee.com/newgateway/vtj.git
synced 2026-09-03 05:14:03 +08:00
fix(agent): 新增运行时验证和计划校验功能
- 在 agent 相关接口中添加 getEngine 方法支持 - useArchitectPlan 中增设 createCheckpoint 以创建项目历史检查点 - 新增 classifyError 方法对错误进行分类 - executeEditorStep 逻辑完善运行时错误验证,增加 verification 结果标记 - agent 类型定义中增加 VerificationResult 和相关属性支持验证信息 - 计划解析函数 parsePlanOutput 增加工具注册校验和安全级别验证逻辑 - 实现 validatePlan,对步骤唯一性、依赖完整性、循环依赖和工具参数进行校验 - 计划工具注册时,对于破坏性和写入操作限制安全级别要求 - 在解析计划输出时,返回详细校验问题并阻止无效计划通过 - 测试用例扩展,覆盖检查点创建、错误分类、计划校验及工具参数验证逻辑 - 更新版本号至 0.14.5
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Copyright (c) 2026, VTJ.PRO All rights reserved.
|
||||
* @name vtj-project-library
|
||||
* @author CHC chenhuachun1549@dingtalk.com
|
||||
* @version 0.14.4
|
||||
* @version 0.14.5
|
||||
* @license <a href="https://vtj.pro/license.html">MIT License</a>
|
||||
*/
|
||||
export const version = '0.14.4';
|
||||
export const version = '0.14.5';
|
||||
@@ -28,6 +28,7 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
updateTopic,
|
||||
saveTrace,
|
||||
setStatus,
|
||||
getEngine,
|
||||
executeEditorStep
|
||||
} = deps;
|
||||
|
||||
@@ -39,9 +40,34 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
content: result.content,
|
||||
error: result.error,
|
||||
tokens: result.tokens || 0,
|
||||
duration: result.duration || 0
|
||||
duration: result.duration || 0,
|
||||
verification: result.verification
|
||||
});
|
||||
|
||||
function createCheckpoint(traceId: string): string | undefined {
|
||||
try {
|
||||
const engine = getEngine?.();
|
||||
const project = engine?.project.value;
|
||||
const history = engine?.projectHistory.value;
|
||||
if (!project || !history) return;
|
||||
history.add(project.toDsl(), `AI 任务检查点 ${traceId}`);
|
||||
return history.items[0]?.id;
|
||||
} catch (error) {
|
||||
console.warn('[useArchitectPlan] 创建检查点失败', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyError(steps: StepRecord[]): string | undefined {
|
||||
const error = steps.find((step) => step.error)?.error || '';
|
||||
if (!error) return;
|
||||
if (/拒绝|审批/.test(error)) return 'approval_rejected';
|
||||
if (/refresh|运行时/.test(error)) return 'runtime';
|
||||
if (/计划|architect/i.test(error)) return 'plan';
|
||||
if (/超时/.test(error)) return 'timeout';
|
||||
return 'execution';
|
||||
}
|
||||
|
||||
async function generateSummary(
|
||||
topicId: string,
|
||||
userId: string,
|
||||
@@ -112,12 +138,17 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
stepsJson: StepRecord[];
|
||||
totalTokens: number;
|
||||
startTime: number;
|
||||
model?: string;
|
||||
checkpointId?: string;
|
||||
}) {
|
||||
await updateTopic({
|
||||
id: opts.topicId,
|
||||
status: opts.status,
|
||||
traceId: opts.traceId
|
||||
});
|
||||
const verifications = opts.stepsJson
|
||||
.map((step) => step.verification)
|
||||
.filter((item) => !!item);
|
||||
await saveTrace({
|
||||
traceId: opts.traceId,
|
||||
topicId: opts.topicId,
|
||||
@@ -125,7 +156,16 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
stepsJson: opts.stepsJson,
|
||||
finalStatus: opts.status,
|
||||
totalTokens: opts.totalTokens,
|
||||
totalDuration: Date.now() - opts.startTime
|
||||
totalDuration: Date.now() - opts.startTime,
|
||||
model: opts.model,
|
||||
agentMode: 'dual',
|
||||
promptVersion: 'architect-editor-v1',
|
||||
toolSchemaVersion: '1',
|
||||
checkpointId: opts.checkpointId,
|
||||
verificationPassed: verifications.length
|
||||
? verifications.every((item) => item.passed)
|
||||
: undefined,
|
||||
errorCategory: classifyError(opts.stepsJson)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +204,9 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
stepsJson: records,
|
||||
status: failed ? 'failed' : 'completed',
|
||||
totalTokens: opts.totalTokens,
|
||||
startTime: opts.startTime
|
||||
startTime: opts.startTime,
|
||||
model: opts.round.modelUsed,
|
||||
checkpointId: opts.round.checkpointId
|
||||
});
|
||||
setStatus(
|
||||
failed
|
||||
@@ -279,7 +321,9 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
planJson: round.architectPlan,
|
||||
stepsJson: records,
|
||||
totalTokens,
|
||||
startTime
|
||||
startTime,
|
||||
model: round.modelUsed,
|
||||
checkpointId: round.checkpointId
|
||||
});
|
||||
|
||||
if (failedStep >= 0) {
|
||||
@@ -337,7 +381,10 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
|
||||
// 解析计划 JSON(括号配对扫描,避免贪婪正则截断)+ 结构校验,
|
||||
// 排除大模型输出的错误占位内容(如 {"error": ...})或空白输出
|
||||
let { plan, error: planError } = parsePlanOutput(round.architectStreamText);
|
||||
let { plan, error: planError } = parsePlanOutput(
|
||||
round.architectStreamText,
|
||||
getEngine?.()?.toolRegistry
|
||||
);
|
||||
let retryCount = 0;
|
||||
|
||||
// 输出无效时自动重试,直至成功、达到上限或取消
|
||||
@@ -373,7 +420,10 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
);
|
||||
planResult = await streamArchitect();
|
||||
totalTokens += planResult.usage?.total_tokens || 0;
|
||||
const parsed = parsePlanOutput(round.architectStreamText);
|
||||
const parsed = parsePlanOutput(
|
||||
round.architectStreamText,
|
||||
getEngine?.()?.toolRegistry
|
||||
);
|
||||
plan = parsed.plan;
|
||||
// 保留模型自报的错误说明(如缺少关键信息),供最终失败时反馈
|
||||
if (parsed.error) planError = parsed.error;
|
||||
@@ -386,6 +436,7 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
}
|
||||
|
||||
round.architectPlan = plan;
|
||||
round.modelUsed = planResult?.modelUsed;
|
||||
|
||||
// ── 保存 Architect chat(保存最终一次流式输出) ──
|
||||
await saveChat(
|
||||
@@ -423,7 +474,8 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
}
|
||||
],
|
||||
totalTokens,
|
||||
startTime
|
||||
startTime,
|
||||
model: round.modelUsed
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -438,6 +490,13 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
|
||||
// ── 分流:无步骤 → 直接回复 ──
|
||||
const steps = round.architectPlan.steps;
|
||||
if (
|
||||
steps?.length &&
|
||||
round.architectPlan.safety !== 'readonly' &&
|
||||
!round.checkpointId
|
||||
) {
|
||||
round.checkpointId = createCheckpoint(traceId);
|
||||
}
|
||||
if (!steps || steps.length === 0) {
|
||||
const answer =
|
||||
round.architectPlan.answer || round.architectPlan.intent || '(无回复)';
|
||||
@@ -450,7 +509,8 @@ export function useArchitectPlan(deps: ArchitectPlanDeps) {
|
||||
planJson: round.architectPlan,
|
||||
stepsJson: [],
|
||||
totalTokens,
|
||||
startTime
|
||||
startTime,
|
||||
model: round.modelUsed
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -418,8 +418,8 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
* ReAct: 修复后自动调用 refresh 验证运行时错误是否消除
|
||||
* @returns true = 仍有错误需继续修复,false = 验证通过或无需验证
|
||||
*/
|
||||
async function applyFixAndVerify(): Promise<boolean> {
|
||||
if (!ctx.needsRefreshVerify || isCancelled()) return false;
|
||||
async function applyFixAndVerify(force = false): Promise<boolean> {
|
||||
if ((!force && !ctx.needsRefreshVerify) || isCancelled()) return false;
|
||||
|
||||
const verifyResult = await executeTool(
|
||||
getEngine()!,
|
||||
@@ -432,6 +432,12 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
if (verifyResult.success && verifyResult.result === true) {
|
||||
// 验证通过:无运行时错误
|
||||
ctx.needsRefreshVerify = false;
|
||||
slot.verification = {
|
||||
passed: true,
|
||||
stage: 'runtime',
|
||||
errors: [],
|
||||
duration: verifyResult.duration
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -440,6 +446,12 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
typeof verifyResult.result === 'string'
|
||||
? verifyResult.result
|
||||
: verifyResult.error || '未知错误';
|
||||
slot.verification = {
|
||||
passed: false,
|
||||
stage: 'runtime',
|
||||
errors: [errMsg],
|
||||
duration: verifyResult.duration
|
||||
};
|
||||
const sourceContext = await getCurrentSourceContext(getEngine());
|
||||
ctx.nextPrompt = `O: 修复已应用,但 refresh 仍检测到运行时错误${sourceContext}\n\n错误信息:\n${errMsg}\n\n请根据上述错误和源码继续修复。`;
|
||||
return true;
|
||||
@@ -497,7 +509,7 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
);
|
||||
|
||||
// ReAct: 修复后自动 refresh 验证
|
||||
if (await applyFixAndVerify()) return 'retry';
|
||||
if (await applyFixAndVerify(true)) return 'retry';
|
||||
|
||||
slot.content = opts.content;
|
||||
slot.done = true;
|
||||
@@ -647,11 +659,25 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
plannedCall.action === 'refresh' &&
|
||||
typeof execResult.result === 'string'
|
||||
) {
|
||||
slot.verification = {
|
||||
passed: false,
|
||||
stage: 'runtime',
|
||||
errors: [execResult.result],
|
||||
duration: execResult.duration
|
||||
};
|
||||
const sourceContext = await getCurrentSourceContext(engine);
|
||||
ctx.needsRefreshVerify = true;
|
||||
ctx.nextPrompt = `O: refresh 检测到运行时错误${sourceContext}\n\n错误信息:\n${execResult.result}\n\n请根据上述错误信息和源码,分析错误原因并修复代码。`;
|
||||
slot.content = '';
|
||||
} else {
|
||||
if (plannedCall.action === 'refresh') {
|
||||
slot.verification = {
|
||||
passed: true,
|
||||
stage: 'runtime',
|
||||
errors: [],
|
||||
duration: execResult.duration
|
||||
};
|
||||
}
|
||||
slot.content = content;
|
||||
slot.done = true;
|
||||
return okResult(content, totalTokens, stepStart, {
|
||||
@@ -877,6 +903,12 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
parsed.tool.action === 'refresh' &&
|
||||
typeof execResult.result === 'string'
|
||||
) {
|
||||
slot.verification = {
|
||||
passed: false,
|
||||
stage: 'runtime',
|
||||
errors: [execResult.result],
|
||||
duration: execResult.duration
|
||||
};
|
||||
// 自动获取当前文件源码,与错误信息一并反馈,避免 LLM 额外调用 getCurrentFileContent
|
||||
const sourceContext = await getCurrentSourceContext(getEngine());
|
||||
ctx.needsRefreshVerify = true;
|
||||
@@ -884,6 +916,15 @@ export function useEditorStep(deps: EditorStepDeps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.tool.action === 'refresh') {
|
||||
slot.verification = {
|
||||
passed: true,
|
||||
stage: 'runtime',
|
||||
errors: [],
|
||||
duration: execResult.duration
|
||||
};
|
||||
}
|
||||
|
||||
// 若步骤指定了目标工具(step.toolName),且当前调用的不是目标工具,
|
||||
// 说明 LLM 正在为修复错误而调用辅助工具(如 getCurrentFileContent),
|
||||
// 此时不应结束步骤,而是继续循环让 LLM 完成修复
|
||||
|
||||
@@ -422,6 +422,7 @@
|
||||
updateTopic,
|
||||
saveTrace,
|
||||
setStatus,
|
||||
getEngine,
|
||||
executeEditorStep
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,15 @@ export interface EditorStepResult {
|
||||
duration?: number;
|
||||
/** 取消时产生的未完成槽位标记(断点恢复时定位用) */
|
||||
aborted?: boolean;
|
||||
/** 代码应用后的运行时验证结果 */
|
||||
verification?: VerificationResult;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
passed: boolean;
|
||||
stage: 'runtime';
|
||||
errors: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 步骤执行返回值(内部使用) */
|
||||
@@ -145,6 +154,7 @@ export interface StepRecord {
|
||||
error: string | null;
|
||||
tokens: number;
|
||||
duration: number;
|
||||
verification?: VerificationResult;
|
||||
}
|
||||
|
||||
// ── 对话轮次相关 ──
|
||||
@@ -172,6 +182,10 @@ export interface ConversationRound {
|
||||
architectError?: string;
|
||||
/** Architect 规划自动重试次数(大模型输出无效时自动重发) */
|
||||
architectRetryCount?: number;
|
||||
/** 本轮实际使用的 Architect 模型 */
|
||||
modelUsed?: string;
|
||||
/** Agent 写入前创建的项目历史检查点 */
|
||||
checkpointId?: string;
|
||||
editorResults: EditorStepResult[];
|
||||
summaryText: string;
|
||||
summaryReasoning: string;
|
||||
@@ -306,6 +320,14 @@ export interface SaveTraceBody {
|
||||
finalStatus: 'failed' | 'completed';
|
||||
totalTokens: number;
|
||||
totalDuration: number;
|
||||
model?: string;
|
||||
agentMode?: 'single' | 'dual';
|
||||
promptVersion?: string;
|
||||
skillVersion?: string;
|
||||
toolSchemaVersion?: string;
|
||||
checkpointId?: string;
|
||||
verificationPassed?: boolean;
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
/** 基础设施依赖 */
|
||||
@@ -398,6 +420,7 @@ export interface ArchitectPlanDeps {
|
||||
updateTopic: (body: UpdateTopicBody) => Promise<any>;
|
||||
saveTrace: (body: SaveTraceBody) => Promise<any>;
|
||||
setStatus: (message: AgentStatusMessage) => void;
|
||||
getEngine?: () => Engine | null;
|
||||
executeEditorStep: (
|
||||
topicId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -5,13 +5,21 @@
|
||||
* 2. 模型自报错误({"error": "..."})→ 返回 error,最终失败时反馈给用户
|
||||
* 3. 无效输出(空白 / 非 JSON / 仅有碎片)→ plan 与 error 均为空
|
||||
*/
|
||||
import type { ToolRegistry } from '../../../../framework';
|
||||
import type { PlanResult } from '../types/agent';
|
||||
import { extractJsonObject } from './json';
|
||||
import { validateToolParameters } from './directTool';
|
||||
|
||||
export interface PlanValidationIssue {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PlanOutputParseResult {
|
||||
plan: PlanResult | null;
|
||||
/** 大模型明确输出的错误说明(如缺少关键信息),最终失败时反馈给用户 */
|
||||
error?: string;
|
||||
issues?: PlanValidationIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,11 +31,142 @@ function normalizePlan(plan: any): PlanResult {
|
||||
...s,
|
||||
type: s.type === 'code' ? 'vue_code' : s.type
|
||||
}))
|
||||
: plan.steps;
|
||||
return { ...plan, steps };
|
||||
: plan.steps === undefined
|
||||
? []
|
||||
: plan.steps;
|
||||
return {
|
||||
...plan,
|
||||
safety: plan.safety || (steps.length === 0 ? 'readonly' : plan.safety),
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePlanOutput(text: string): PlanOutputParseResult {
|
||||
export function validatePlan(
|
||||
plan: PlanResult,
|
||||
registry?: ToolRegistry
|
||||
): PlanValidationIssue[] {
|
||||
const issues: PlanValidationIssue[] = [];
|
||||
const steps = plan.steps;
|
||||
const validSafety = ['readonly', 'write', 'destructive'];
|
||||
const validTypes = ['tool_call', 'vue_code', 'diff', 'text'];
|
||||
|
||||
if (!validSafety.includes(plan.safety)) {
|
||||
issues.push({ path: 'safety', message: '安全等级无效' });
|
||||
}
|
||||
if (!Array.isArray(steps)) {
|
||||
issues.push({ path: 'steps', message: '必须是数组' });
|
||||
return issues;
|
||||
}
|
||||
if (
|
||||
plan.contextKeys !== undefined &&
|
||||
(!Array.isArray(plan.contextKeys) ||
|
||||
plan.contextKeys.some((key) => typeof key !== 'string'))
|
||||
) {
|
||||
issues.push({ path: 'contextKeys', message: '必须是字符串数组' });
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const path = `steps[${index}]`;
|
||||
if (!step || typeof step !== 'object') {
|
||||
issues.push({ path, message: '必须是对象' });
|
||||
continue;
|
||||
}
|
||||
if (typeof step.id !== 'string' || !step.id.trim()) {
|
||||
issues.push({ path: `${path}.id`, message: '不能为空' });
|
||||
} else if (ids.has(step.id)) {
|
||||
issues.push({ path: `${path}.id`, message: '步骤 ID 重复' });
|
||||
} else {
|
||||
ids.add(step.id);
|
||||
}
|
||||
if (!validTypes.includes(step.type)) {
|
||||
issues.push({ path: `${path}.type`, message: '步骤类型无效' });
|
||||
}
|
||||
if (typeof step.description !== 'string' || !step.description.trim()) {
|
||||
issues.push({ path: `${path}.description`, message: '不能为空' });
|
||||
}
|
||||
if (step.dependsOn !== undefined && !Array.isArray(step.dependsOn)) {
|
||||
issues.push({ path: `${path}.dependsOn`, message: '必须是数组' });
|
||||
}
|
||||
if (step.target?.includes('{{')) {
|
||||
issues.push({ path: `${path}.target`, message: '不能使用模板占位符' });
|
||||
}
|
||||
|
||||
if (step.type === 'tool_call') {
|
||||
if (typeof step.toolName !== 'string' || !step.toolName.trim()) {
|
||||
issues.push({ path: `${path}.toolName`, message: '不能为空' });
|
||||
continue;
|
||||
}
|
||||
const tool = registry?.get(step.toolName);
|
||||
if (registry && !tool) {
|
||||
issues.push({ path: `${path}.toolName`, message: '工具不存在' });
|
||||
continue;
|
||||
}
|
||||
if (step.parameters !== undefined && !Array.isArray(step.parameters)) {
|
||||
issues.push({ path: `${path}.parameters`, message: '必须是数组' });
|
||||
} else if (
|
||||
tool &&
|
||||
step.parameters &&
|
||||
!validateToolParameters(step.parameters, tool.parameters)
|
||||
) {
|
||||
issues.push({
|
||||
path: `${path}.parameters`,
|
||||
message: '参数不符合工具定义'
|
||||
});
|
||||
}
|
||||
if (tool?.risk === 'destructive' && plan.safety !== 'destructive') {
|
||||
issues.push({
|
||||
path: 'safety',
|
||||
message: '破坏性工具必须标记 destructive'
|
||||
});
|
||||
}
|
||||
if (tool?.risk === 'write' && plan.safety === 'readonly') {
|
||||
issues.push({ path: 'safety', message: '写入工具不能标记 readonly' });
|
||||
}
|
||||
} else if (
|
||||
(step.type === 'vue_code' || step.type === 'diff') &&
|
||||
plan.safety === 'readonly'
|
||||
) {
|
||||
issues.push({ path: 'safety', message: '代码写入不能标记 readonly' });
|
||||
}
|
||||
}
|
||||
|
||||
const graph = new Map(
|
||||
steps.map((step) => [step.id, step.dependsOn || []] as const)
|
||||
);
|
||||
for (const [index, step] of steps.entries()) {
|
||||
for (const dependency of step.dependsOn || []) {
|
||||
if (!ids.has(dependency)) {
|
||||
issues.push({
|
||||
path: `steps[${index}].dependsOn`,
|
||||
message: `引用了不存在的步骤 ${dependency}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const hasCycle = (id: string): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const cyclic = (graph.get(id) || []).some(
|
||||
(dependency) => graph.has(dependency) && hasCycle(dependency)
|
||||
);
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return cyclic;
|
||||
};
|
||||
if (steps.some((step) => hasCycle(step.id))) {
|
||||
issues.push({ path: 'steps', message: '步骤依赖存在循环' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function parsePlanOutput(
|
||||
text: string,
|
||||
registry?: ToolRegistry
|
||||
): PlanOutputParseResult {
|
||||
const json = extractJsonObject(text);
|
||||
if (!json) return { plan: null };
|
||||
let parsed: any;
|
||||
@@ -40,13 +179,25 @@ export function parsePlanOutput(text: string): PlanOutputParseResult {
|
||||
if (typeof parsed?.error === 'string' && parsed.error.trim()) {
|
||||
return { plan: null, error: parsed.error.trim() };
|
||||
}
|
||||
let plan: PlanResult | null = null;
|
||||
// 直接回答(无步骤)需携带 answer 文本
|
||||
if (typeof parsed?.answer === 'string' && parsed.answer.trim()) {
|
||||
return { plan: normalizePlan(parsed) };
|
||||
plan = normalizePlan(parsed);
|
||||
}
|
||||
// 规划需携带 intent(steps 缺失时回退为直接回答,兼容旧行为)
|
||||
if (typeof parsed?.intent === 'string' && parsed.intent.trim()) {
|
||||
return { plan: normalizePlan(parsed) };
|
||||
if (!plan && typeof parsed?.intent === 'string' && parsed.intent.trim()) {
|
||||
plan = normalizePlan(parsed);
|
||||
}
|
||||
return { plan: null };
|
||||
if (!plan) return { plan: null };
|
||||
const issues = validatePlan(plan, registry);
|
||||
return issues.length
|
||||
? {
|
||||
plan: null,
|
||||
issues,
|
||||
error: issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => `${issue.path}: ${issue.message}`)
|
||||
.join(';')
|
||||
}
|
||||
: { plan };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parsePlanOutput } from '../../src/components/widgets/agent/utils/plan';
|
||||
import { ToolRegistry } from '../../src/framework';
|
||||
import {
|
||||
parsePlanOutput,
|
||||
validatePlan
|
||||
} from '../../src/components/widgets/agent/utils/plan';
|
||||
|
||||
describe('parsePlanOutput', () => {
|
||||
it('将服务端协议步骤类型 code 归一化为 vue_code', () => {
|
||||
@@ -63,4 +67,104 @@ describe('parsePlanOutput', () => {
|
||||
expect(parsePlanOutput('')).toEqual({ plan: null });
|
||||
expect(parsePlanOutput('随便说说')).toEqual({ plan: null });
|
||||
});
|
||||
|
||||
it('拒绝重复步骤、失效依赖和循环依赖', () => {
|
||||
const { plan, issues } = parsePlanOutput(
|
||||
JSON.stringify({
|
||||
intent: '更新页面',
|
||||
safety: 'write',
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'text',
|
||||
description: '第一步',
|
||||
dependsOn: ['s2']
|
||||
},
|
||||
{
|
||||
id: 's1',
|
||||
type: 'text',
|
||||
description: '第二步',
|
||||
dependsOn: ['missing']
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
type: 'text',
|
||||
description: '第三步',
|
||||
dependsOn: ['s3']
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
type: 'text',
|
||||
description: '第四步',
|
||||
dependsOn: ['s2']
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(plan).toBeNull();
|
||||
expect(issues?.map((issue) => issue.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'步骤 ID 重复',
|
||||
'引用了不存在的步骤 missing',
|
||||
'步骤依赖存在循环'
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('按注册工具校验工具名、参数和安全等级', () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({
|
||||
name: 'removePage',
|
||||
description: '删除页面',
|
||||
risk: 'destructive',
|
||||
parameters: [{ name: 'id', type: 'string', required: true }],
|
||||
handler: async () => true
|
||||
});
|
||||
const result = parsePlanOutput(
|
||||
JSON.stringify({
|
||||
intent: '删除页面',
|
||||
safety: 'write',
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'tool_call',
|
||||
description: '删除',
|
||||
toolName: 'removePage',
|
||||
parameters: [1]
|
||||
}
|
||||
]
|
||||
}),
|
||||
registry
|
||||
);
|
||||
expect(result.plan).toBeNull();
|
||||
expect(result.issues?.map((issue) => issue.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'参数不符合工具定义',
|
||||
'破坏性工具必须标记 destructive'
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('拒绝不存在的工具和 target 模板占位符', () => {
|
||||
const registry = new ToolRegistry();
|
||||
const issues = validatePlan(
|
||||
{
|
||||
intent: '更新页面',
|
||||
safety: 'write',
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'tool_call',
|
||||
description: '调用工具',
|
||||
toolName: 'missing',
|
||||
target: '{{step_0.id}}'
|
||||
}
|
||||
]
|
||||
},
|
||||
registry
|
||||
);
|
||||
expect(issues.map((issue) => issue.message)).toEqual(
|
||||
expect.arrayContaining(['工具不存在', '不能使用模板占位符'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,10 @@ describe('useArchitectPlan.executeArchitectPlan', () => {
|
||||
});
|
||||
|
||||
it('executes all steps, generates a summary and completes the topic', async () => {
|
||||
const checkpointHistory = {
|
||||
items: [] as Array<{ id: string }>,
|
||||
add: vi.fn(() => checkpointHistory.items.unshift({ id: 'checkpoint' }))
|
||||
};
|
||||
const deps = createDeps({
|
||||
streamCompletion: vi
|
||||
.fn()
|
||||
@@ -218,7 +222,11 @@ describe('useArchitectPlan.executeArchitectPlan', () => {
|
||||
error: null,
|
||||
tokens: 5,
|
||||
duration: 100
|
||||
}))
|
||||
})),
|
||||
getEngine: () => ({
|
||||
project: { value: { toDsl: () => ({ id: 'project' }) } },
|
||||
projectHistory: { value: checkpointHistory }
|
||||
})
|
||||
});
|
||||
const round = createRound();
|
||||
const { executeArchitectPlan } = useArchitectPlan(deps);
|
||||
@@ -252,6 +260,8 @@ describe('useArchitectPlan.executeArchitectPlan', () => {
|
||||
tokens: 5
|
||||
});
|
||||
expect(traceBody.totalTokens).toBe(60);
|
||||
expect(checkpointHistory.add).toHaveBeenCalledOnce();
|
||||
expect(traceBody.checkpointId).toBe('checkpoint');
|
||||
});
|
||||
|
||||
it('marks the topic as failed when a step reports an error', async () => {
|
||||
|
||||
Reference in New Issue
Block a user