工具总线 + Agent 开环:声明式工具、人类工具与 LLM 五件系统内化(Mastra tool 形状)

用户定:Agent 调用工具一律**声明式**;人类工具与 LLM 工具要系统内化;
工具总线基于 Mastra tool 设计。

**声明面**(`toolbox/internal/domain/tool-bus.ts`,纯逻辑零框架):
`compileToolBus(声明 × 目录) → 一张清单` —— 编译期定死这一单能调什么,
清单之外结构上调不到(白名单在缝里再把一次关);缺件/停用如实报不静默。
三路同一形状:
  · `function` 功能工具:按活动白名单查目录,带出效应(read/write)
  · `human` **人类工具**(系统内化、零声明即得):调人 = 发工单 + 立监听听人 ——
    这是 Harness 给的能力,不进目录、不需白名单,每个活动天然可以调人
  · `llm` **LLM 五件**(系统内化):extract/classify/summarize/judge/rewrite,
    各有入参出参契约、经模型网关一缝;**仅当活动声明了模型用途名才上总线**
    (没声明却会问模型 = 越权,用量也无从归因);提示词归系统,活动改不了

**装配面**(`app/tool-bus.ts`,Mastra `createTool`):清单 → 可调用工具,
JSON Schema 现译 zod(模型照它填参,填错当场被拦);三路各穿各的缝:
写类工具**过程中一律不落地**,只登记动作意向(骗 Agent 说已执行是最坏选项);
调人把卡号记进 trace,引擎据此把这一拍挂起。

**Agent 开环**(`app/mastra-agent-runner.ts`,M5-2 第二实现,用户选的路线):
Mastra `Agent` + 工具总线,拍内自主连环调工具,拍末交回结构化结论。
放开的是工具循环不是规矩:写类不落地、终局唯放行、调人即挂起(新增
`AgentAction.asked` 型 —— 卡已由总线发出,引擎只收尾,不重复发卡)、
用量按 usage 计入预算。`JCAI_AGENT_OPEN_LOOP=1` 开启,缺省仍是单拍版。
`ModelGateway.resolve` 新增:名 → 四元(密钥当场解出,只给 app 层装配用)。

代价如实记 debt M5-2:开环下模型**逐轮**调用不经录制回放(tool-calling 往返
`generate` 的 text-in/text-out 承载不了)—— 凭证隔离与工具调用一缝完整。

测试:tool-bus 六例(白名单/缺件/停用/人类工具恒在/LLM 按声明/五件契约);
四闸(typecheck/lint/depcruise/test)全过;spike 目录排除出 lint(验证性原型)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 23:46:12 +08:00
co-authored by Claude Fable 5
parent 7c609e852c
commit f96eff906a
32 changed files with 6400 additions and 15 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* 模拟主工程的“实例/任务”台账。
*
* 主工程语义:回边 reopen = 回到 A4 **重开一个新实例**(新 instance 号、新任务 ID,
* 旧实例留痕不动)。这里用一个进程内表来记账,用来验证「哪条路线能自然地拿到新实例号」。
*/
export type NodeId = 'A4' | 'A5' | 'A6';
export type InstanceRow = {
/** 实例号:同一节点上单调递增,A4#1 / A4#2 ... */
instanceNo: number;
nodeId: NodeId;
/** 任务 ID:每个实例一个,全局唯一 */
taskId: string;
/** 哪个 run 起的它(路线 B 会出现多个 runId) */
runId: string;
/** 哪一轮循环起的它(路线 A 才有意义) */
iteration: number;
/** 由哪条边重开(E10 / E12);首开为 null */
reopenedBy: string | null;
status: 'open' | 'closed';
outcome?: string;
openedAt: number;
closedAt?: number;
};
export class Ledger {
readonly rows: InstanceRow[] = [];
private seq = 0;
openInstance(args: {
nodeId: NodeId;
runId: string;
iteration: number;
reopenedBy?: string | null;
}): InstanceRow {
const instanceNo = this.rows.filter((r) => r.nodeId === args.nodeId).length + 1;
const row: InstanceRow = {
instanceNo,
nodeId: args.nodeId,
taskId: `T-${args.nodeId}-${String(++this.seq).padStart(3, '0')}`,
runId: args.runId,
iteration: args.iteration,
reopenedBy: args.reopenedBy ?? null,
status: 'open',
openedAt: Date.now(),
};
this.rows.push(row);
return row;
}
closeTask(taskId: string, outcome: string): InstanceRow {
const row = this.rows.find((r) => r.taskId === taskId);
if (!row) throw new Error(`ledger: unknown taskId ${taskId}`);
row.status = 'closed';
row.outcome = outcome;
row.closedAt = Date.now();
return row;
}
dump(title: string): void {
console.log(`\n ── 台账 ${title} ──`);
console.log(
' ' +
['节点', '实例', '任务ID', '轮次', '重开自', '状态', '结论', 'runId'].join('\t'),
);
for (const r of this.rows) {
console.log(
' ' +
[
r.nodeId,
`#${r.instanceNo}`,
r.taskId,
`it=${r.iteration}`,
r.reopenedBy ?? '-',
r.status,
r.outcome ?? '-',
r.runId.slice(0, 8),
].join('\t'),
);
}
}
}
/** 外部注入的“决策脚本”:模拟人在另一个调用链里点了按钮 */
export type Decision =
| { node: 'A4'; price: number }
| { node: 'A5'; verdict: 'pass' | 'reject' }
| { node: 'A6'; outcome: 'won' | 'revise' };
export class DecisionScript {
private i = 0;
constructor(private readonly script: Decision[]) {}
next(expectNode: NodeId): Decision {
const d = this.script[this.i++];
if (!d) throw new Error(`decision script exhausted at ${expectNode}`);
if (d.node !== expectNode) {
throw new Error(`decision script mismatch: expect ${expectNode}, got ${d.node}`);
}
return d;
}
get consumed(): number {
return this.i;
}
}
export const log = (...args: unknown[]): void => console.log(...args);
export const banner = (s: string): void =>
console.log(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`);