fix(agent): 新增运行时验证和计划校验功能

- 在 agent 相关接口中添加 getEngine 方法支持
- useArchitectPlan 中增设 createCheckpoint 以创建项目历史检查点
- 新增 classifyError 方法对错误进行分类
- executeEditorStep 逻辑完善运行时错误验证,增加 verification 结果标记
- agent 类型定义中增加 VerificationResult 和相关属性支持验证信息
- 计划解析函数 parsePlanOutput 增加工具注册校验和安全级别验证逻辑
- 实现 validatePlan,对步骤唯一性、依赖完整性、循环依赖和工具参数进行校验
- 计划工具注册时,对于破坏性和写入操作限制安全级别要求
- 在解析计划输出时,返回详细校验问题并阻止无效计划通过
- 测试用例扩展,覆盖检查点创建、错误分类、计划校验及工具参数验证逻辑
- 更新版本号至 0.14.5
This commit is contained in:
“chenhuachun”
2026-08-23 18:07:51 +08:00
parent b315b88d0f
commit b29e8427cd
8 changed files with 412 additions and 22 deletions

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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 完成修复

View File

@@ -422,6 +422,7 @@
updateTopic,
saveTrace,
setStatus,
getEngine,
executeEditorStep
});

View File

@@ -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,

View File

@@ -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);
}
// 规划需携带 intentsteps 缺失时回退为直接回答,兼容旧行为)
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 };
}

View File

@@ -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(['工具不存在', '不能使用模板占位符'])
);
});
});

View File

@@ -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 () => {