/** * module: loop-engine —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 */ import type { LoopModel, StaffId, TaskId } from '@contracts'; import type { CaseModel } from './internal/domain/cmmn-parse.ts'; import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts'; import type { AgentTurn } from './contract.ts'; export type Clock = { now(): Date; }; /** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */ export type LoopStateStore = { save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime, pending?: PendingAcceptance | null): Promise; load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance; } | undefined>; }; /** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */ export type Sfeel = { condition(expression: string, scope: Readonly>): boolean; expression(expression: string, scope: Readonly>): unknown; unaryTest(cell: string, value: unknown, scope: Readonly>): boolean; }; /** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */ export type TaskDataGateway = { view(taskId: TaskId): Promise>; /** * 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。 * * 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 —— * 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。 */ writable(taskId: TaskId): Promise; }>>; /** * 写草稿,并回报**这一笔到底有没有改变什么**。 * * 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 —— * 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。 */ draftWrite(input: { taskId: TaskId; domain: string; object: string; key: Record; patch: Record; }): Promise<{ changed: boolean; }>; /** * **撤掉本单草稿层的一行** —— 写错了得有退路。 * * 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上, * `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型 * 以为撤掉了,下一拍判据照旧不过,它又去撤一次。 */ draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record; }): Promise<{ dropped: boolean; }>; }; /** * Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。 * **回合由 usecase 驱动,不放开自由 maxSteps,预算在环外由 Harness 强制。** */ export type AgentPort = { decide(input: { taskId: TaskId; /** 叙事面:说明书 + 活动记忆选摘 + 任务记忆窗口 */ narrative: string; enabled: EnabledSet; scope: Record; /** * 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。 * * 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 —— * 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」), * 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。 */ tools: Array<{ id: string; description: string; input: Record; effect: 'read' | 'write'; }>; /** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */ writable: Array<{ domain: string; object: string; label: string; keys: string[]; fields: Array<{ name: string; label: string; type?: string; }>; }>; }): Promise; }; /** 工具 / 人类工具 / 监听 —— 三型行动的落点 */ export type ToolGateway = { invoke(input: { tool: string; input: Record; activityId: string; taskId: TaskId; }): Promise<{ ok: boolean; output?: Record; error?: { message: string; }; /** * 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。 * * 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。 * 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent * 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。 */ awaitingHuman?: { readonly workOrderId: string; }; }>; isWriteEffect(tool: string): Promise; registerIntent(input: { taskId: TaskId; tool: string; input: Record; label: string; }): Promise; /** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */ busOf(activityId: string): Promise; effect: 'read' | 'write'; }>>; }; /** * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— * 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔: * `pass` 才是终局(released);**非 pass = 打回重做,不是终局** —— * 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型, * 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22, * 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有 * 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。 */ export type PendingAcceptance = { readonly acceptanceCardId: string; readonly verdict?: 'pass' | 'reject'; /** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */ readonly comment?: string; readonly selectedPorts?: Record; }; export type CardGateway = { /** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */ entry(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; ledger: string; key: Readonly>; fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean; }>; /** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */ also?: ReadonlyArray<{ title: string; ledger: string; key: Readonly>; fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean; }>; }>; basis?: ReadonlyArray<{ k: string; v: string; }>; }): Promise<{ id: string; }>; issue(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; workOrder: 'execution' | 'acceptance'; /** 这张卡挂在哪个计划项上 —— 引擎按它查剧本声明(如 `cardVariant`),不靠模型抄卡骨架;引擎自己发的卡(验收/兜底)没有项 */ itemId?: string | undefined; card: Record; }): Promise<{ id: string; }>; /** * 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。 * * 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」, * 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。 * SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。 */ ask(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; fields: ReadonlyArray<{ key: string; label: string; hint?: string; }>; basis?: ReadonlyArray<{ k: string; v: string; }>; }): Promise<{ id: string; }>; /** * 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。 * * 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。 * 两条路都合法、选哪条是业务判断的时候,该发的是这一件。 */ decide(input: { taskId: TaskId; ownerId: StaffId; humanCallCap: number; ask: string; options: ReadonlyArray<{ value: string; label: string; detail?: string; consequence?: string; recommended?: boolean; }>; basis?: ReadonlyArray<{ k: string; v: string; }>; allowCustom?: boolean; /** 这张卡挂在哪个计划项上 —— 组装层按它查剧本声明(`cardVariant: 'review'` = 摆成验收工单) */ itemId?: string | undefined; /** * **这张卡穿哪副面孔,引擎已经按剧本定好**(2026-08-27):模型没给 itemId、或给的项挂不上时, * 组装层按 itemId 查剧本就查不到,卡就退成「需要你定」—— 而这一步的剧本明明写着 `cardVariant: 'review'`。 * 引擎知道当下在办哪一步,由它把面孔定下来递过去;组装层只在这里没给时才自己按 itemId 查。 */ variant?: 'review' | undefined; /** 人工验收清单(剧本 `acceptanceChecklist`)—— 审核卡上一条一枚「达标 / 不达标」开关 */ checklist?: ReadonlyArray | undefined; }): Promise<{ id: string; }>; /** * **这一单还有哪几张卡在等人交回**(2026-08-27,用户:「你叫了人我都没填你就直接推进了」)。 * 每拍开头问一次:有卡没答,这一拍不问模型 —— 叫了人就等人; * 任何多余的唤醒(迟到的回传事件、另一台后端的推进器)都推不动它。 */ unanswered(taskId: TaskId): Promise>; }; export type ListenerRegistrar = { register(input: { taskId: TaskId; eventType: 'data' | 'time' | 'human'; keyFilter: Record; deadline: string; }): Promise<{ id: string; }>; /** * **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。 * * 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。 * 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执, * 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 —— * 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。** */ declared(taskId: TaskId): Promise; }>>; }; /** * 视图流控 —— app 注入 `interaction`(L3)。 * 「loop:AI 按定位与局面临场组织视图流」(AM 07)。MVP 每回合推一次**案例全景**: * 阶段与计划项的生命周期态、当下可为、目标缺口 —— 这些是语义引擎算出来的确定事实, * 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。 */ export type ViewGateway = { /** * 推一帧画面。**回报推没推上** —— 从前是 `Promise` 且模版查不到就静默 return: * 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字, * 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。 * 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。 */ push(input: { taskId: TaskId; activityId: string; template: string; scope: Record; }): Promise<{ pushed: boolean; templates: string[]; }>; }; /** * 技能 —— app 注入 `skill-engine`(L4)。 * 「渐进披露:常驻上下文**仅有技能目录**,按事加载正文、按需下钻引用」(AM 08)。 */ export type SkillGateway = { /** 目录:id + 何时适用 —— 只给这些,正文等 AI 说要才加载 */ index(activityId: string): Promise>; /** 装配入信封:这一回合装配了哪些技能及其版本,记入任务记忆 */ stamp(activityId: string, skillIds: readonly string[]): Promise>; }; export type MemoryRecorder = { append(input: { taskId: TaskId; kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration'; title: string; detail?: Record; }): Promise; /** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */ loadActivityMemory(activityId: string, limit: number): Promise>; /** * 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。 * * 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。 * **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」, * 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里 * 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断, * 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。 */ window(taskId: TaskId, size?: number): Promise | undefined; }>>; }; export type TaskContext = { of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number; }>; /** * 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。 * * 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 —— * 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里 * 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了, * 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。 * 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。 */ machineCriteria(taskId: TaskId): Promise>; };