Archived
feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数
三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0); 值没变的写静默跳过(配方不是模型,原地打转不必被看见); 出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。 主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||
`AutomaticOnWorldTouchingError`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext` `AnalyzeGateway`
|
||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoRecipe } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
|
||||
|
||||
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
|
||||
function harness(seed: Record<string, unknown> = {}) {
|
||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const notes: string[] = [];
|
||||
const view = (): Record<string, unknown> => {
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||
return { ...seed, ...mine };
|
||||
};
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve(view()),
|
||||
writable: () =>
|
||||
Promise.resolve([
|
||||
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
|
||||
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
|
||||
]),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
|
||||
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
|
||||
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
|
||||
},
|
||||
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issue: () => Promise.resolve({ id: 'card-1' }),
|
||||
decide: () => Promise.resolve({ id: 'card-2' }),
|
||||
entry: () => Promise.resolve({ id: 'card-3' }),
|
||||
ask: () => Promise.resolve({ id: 'card-4' }),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||
sfeel: {
|
||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||
if (expr === 'never') return false;
|
||||
if (expr === 'always') return true;
|
||||
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
||||
throw new Error(`没教过这句:${expr}`);
|
||||
},
|
||||
expression: () => undefined,
|
||||
unaryTest: () => false,
|
||||
},
|
||||
};
|
||||
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
|
||||
return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
|
||||
}
|
||||
|
||||
describe('配方执行器', () => {
|
||||
it('project once:写一行,主键与其余列分开', async () => {
|
||||
const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' });
|
||||
const recipe: AutoRecipe = {
|
||||
let: { evalRef: 'W05-1' },
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'sub_eval.part',
|
||||
from: { once: true },
|
||||
fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await runner.run(recipe, ctx);
|
||||
expect(out.wrote).toBe(1);
|
||||
expect([...drafts.values()][0]).toEqual({
|
||||
domain: 'sub_eval',
|
||||
object: 'part',
|
||||
key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
|
||||
patch: { role: 'original' },
|
||||
});
|
||||
});
|
||||
|
||||
it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
const out = await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] },
|
||||
ctx,
|
||||
);
|
||||
expect(out.wrote).toBe(0);
|
||||
expect(drafts.size).toBe(0);
|
||||
});
|
||||
|
||||
it('when 为假就跳过这一笔', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx);
|
||||
expect(drafts.size).toBe(0);
|
||||
});
|
||||
|
||||
it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run(
|
||||
{
|
||||
do: [
|
||||
{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } },
|
||||
{ kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const params = [...drafts.values()].filter((d) => d.object === 'parameter');
|
||||
expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => {
|
||||
const { runner, ctx } = harness({});
|
||||
const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] };
|
||||
await runner.run(recipe, ctx);
|
||||
const second = await runner.run(recipe, ctx);
|
||||
expect(second.wrote).toBe(0);
|
||||
});
|
||||
|
||||
it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx);
|
||||
expect(drafts.size).toBe(2);
|
||||
const out = await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
|
||||
ctx,
|
||||
);
|
||||
expect(out.dropped).toBe(1);
|
||||
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('say:进工作日志,归系统', async () => {
|
||||
const { runner, notes, ctx } = harness({ n: 2 });
|
||||
await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx);
|
||||
expect(notes).toContain('已核对 2 颗料');
|
||||
});
|
||||
|
||||
it('地址不在台账声明里 → 当场交人,不重试', async () => {
|
||||
const { runner, ctx } = harness({});
|
||||
const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx);
|
||||
expect(out.failedAt?.index).toBe(0);
|
||||
expect(out.failedAt?.message).toContain('nope.nope');
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,7 @@ function harness(
|
||||
} as never;
|
||||
},
|
||||
} as never,
|
||||
analyze: { run: async () => ({ object: {} }) } as never,
|
||||
tools: {
|
||||
invoke: async (input: { tool: string }) => {
|
||||
toolCalls.push(input.tool);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
|
||||
*
|
||||
* 三条硬规矩:
|
||||
* ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
|
||||
* 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
|
||||
* ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
|
||||
* 「写了等于没写」那条判据是拦模型空转的,配方不是模型;
|
||||
* 发卡先看 `carded`(worldTouched),发过就不再发。
|
||||
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
|
||||
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
||||
*/
|
||||
import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts';
|
||||
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
|
||||
import { expandSource } from '../domain/auto-source.ts';
|
||||
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
|
||||
|
||||
export class AutoRecipeError extends Error {
|
||||
constructor(
|
||||
readonly actionIndex: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AutoRecipeError';
|
||||
}
|
||||
}
|
||||
|
||||
export type AutoRunnerDeps = {
|
||||
readonly data: TaskDataGateway;
|
||||
readonly cards: CardGateway;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly analyze: AnalyzeGateway;
|
||||
readonly sfeel: Sfeel;
|
||||
};
|
||||
|
||||
export type AutoContext = {
|
||||
readonly taskId: TaskId;
|
||||
readonly stepId: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly humanCallCap: number;
|
||||
/** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */
|
||||
readonly quiet: boolean;
|
||||
/** 这一步已经发过卡(worldTouched) */
|
||||
readonly carded: boolean;
|
||||
};
|
||||
|
||||
export type AutoOutcome = {
|
||||
readonly wrote: number;
|
||||
readonly dropped: number;
|
||||
readonly issuedCardId?: string;
|
||||
readonly submit?: { readonly rationale: string };
|
||||
readonly failedAt?: { readonly index: number; readonly message: string };
|
||||
};
|
||||
|
||||
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
|
||||
|
||||
export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
|
||||
} {
|
||||
return {
|
||||
async run(recipe, ctx) {
|
||||
const writable = await deps.data.writable(ctx.taskId);
|
||||
const results: Record<string, unknown[]> = {};
|
||||
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
let dirty = false;
|
||||
let wrote = 0;
|
||||
let dropped = 0;
|
||||
let issuedCardId: string | undefined;
|
||||
let submit: { rationale: string } | undefined;
|
||||
|
||||
const condition = deps.sfeel.condition;
|
||||
const lets: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: AutoAction,
|
||||
index: number,
|
||||
base: ExprCtx,
|
||||
context: AutoContext,
|
||||
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
|
||||
if (action.kind === 'say') {
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'result',
|
||||
title: evalText(action.text, base),
|
||||
detail: { auto: `${context.stepId}#${index + 1}` },
|
||||
});
|
||||
return { wrote: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
if (action.kind === 'project') {
|
||||
const [domain, object] = action.into.split('.');
|
||||
const decl = writable.find((w) => w.domain === domain && w.object === object);
|
||||
if (decl === undefined) {
|
||||
throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`);
|
||||
}
|
||||
const before = rowsAt(base.scope, `mine:${action.into}`);
|
||||
const kept = new Set<string>();
|
||||
let localWrote = 0;
|
||||
for (const { row, outer } of expandSource(action.from, base)) {
|
||||
const computed: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(action.fields)) {
|
||||
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
|
||||
}
|
||||
const key: Record<string, string> = {};
|
||||
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
|
||||
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */
|
||||
if (decl.keys.some((column) => key[column] === '')) continue;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
|
||||
const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
|
||||
if (changed) localWrote += 1;
|
||||
kept.add(keyText(key));
|
||||
}
|
||||
let localDropped = 0;
|
||||
if (action.prune === true) {
|
||||
for (const stale of before) {
|
||||
const key: Record<string, string> = {};
|
||||
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
|
||||
if (kept.has(keyText(key))) continue;
|
||||
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
|
||||
if (gone) localDropped += 1;
|
||||
}
|
||||
}
|
||||
if (localWrote > 0 || localDropped > 0) {
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'result',
|
||||
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into },
|
||||
});
|
||||
}
|
||||
return { wrote: localWrote, dropped: localDropped };
|
||||
}
|
||||
|
||||
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
|
||||
}
|
||||
|
||||
for (const [index, action] of recipe.do.entries()) {
|
||||
try {
|
||||
if (dirty) {
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
dirty = false;
|
||||
}
|
||||
const base: ExprCtx = { scope, lets, condition };
|
||||
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
||||
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
|
||||
if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;
|
||||
|
||||
const done = await runAction(action, index, base, ctx);
|
||||
wrote += done.wrote;
|
||||
dropped += done.dropped;
|
||||
if (done.wrote > 0 || done.dropped > 0) dirty = true;
|
||||
if (done.cardId !== undefined) issuedCardId = done.cardId;
|
||||
if (done.submit !== undefined) submit = done.submit;
|
||||
if (done.stop) break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await deps.memory.append({
|
||||
taskId: ctx.taskId,
|
||||
kind: 'result',
|
||||
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
||||
detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false },
|
||||
});
|
||||
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
||||
}
|
||||
}
|
||||
return {
|
||||
wrote,
|
||||
dropped,
|
||||
...(issuedCardId === undefined ? {} : { issuedCardId }),
|
||||
...(submit === undefined ? {} : { submit }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract
|
||||
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
||||
import type {
|
||||
AgentPort,
|
||||
AnalyzeGateway,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
@@ -53,6 +54,7 @@ export type TurnRunnerDeps = {
|
||||
readonly skills: SkillGateway;
|
||||
readonly tasks: TaskContext;
|
||||
readonly clock: Clock;
|
||||
readonly analyze: AnalyzeGateway;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -314,3 +314,24 @@ export type TaskContext = {
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
|
||||
*
|
||||
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
|
||||
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
|
||||
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
|
||||
*/
|
||||
export type AnalyzeGateway = {
|
||||
run(input: {
|
||||
readonly taskId: TaskId;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
readonly instruction: string;
|
||||
/** 系统组装好的入参 —— 一行一条 */
|
||||
readonly payload: readonly unknown[];
|
||||
readonly outputSchema: Readonly<Record<string, unknown>>;
|
||||
/** 归因:`<步名>#<第几笔>` */
|
||||
readonly label: string;
|
||||
}): Promise<{ readonly object: Readonly<Record<string, unknown>> }>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user