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:
xcs
2026-08-29 03:41:34 +08:00
co-authored by Claude Opus 5
parent 58ff4f1b90
commit b0a3cc3a55
8 changed files with 375 additions and 1 deletions
+22
View File
@@ -389,3 +389,25 @@ 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>>;
}>;
};
+1
View File
@@ -2289,6 +2289,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
tasks: taskContext,
clock,
analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },
});
/**
+1 -1
View File
@@ -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;
};
/**
+21
View File
@@ -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>> }>;
};