工具总线 + 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
+15
View File
@@ -76,12 +76,27 @@ export type ModelResult = {
readonly mode: GatewayMode;
};
/** 解好的模型端点(四元 + 明文密钥)—— app 层装配专用,见 ModelGateway.resolve */
export type ResolvedEndpoint = {
readonly provider: LlmProvider;
readonly baseUrl: string;
readonly model: string;
readonly apiKey: string;
};
/**
* 模型网关 —— 活动基座六件里的「LLM 调用」。
* 活动只见自定义模型名;解析、凭证、录制回放全在此。
*/
export type ModelGateway = {
readonly mode: GatewayMode;
/**
* 解绑定:名 → 四元(含当场解出的密钥)—— **只给 app 层装配用**:
* Mastra Agent 那类需要真模型对象做 tool-calling 往返的场合,
* `generate` 的 text-in/text-out 抽象承载不了。
* 密钥与 endpoint 仍不入 domain、不入包、不入留痕明文(OS 7.3 凭证隔离)。
*/
resolve(modelName: string): Promise<ResolvedEndpoint>;
generate(call: ModelCall): Promise<ModelResult>;
/** 换模式的网关 —— 回放审用 replay 模式重跑引擎(§7.3) */
withMode(mode: GatewayMode): ModelGateway;
+1
View File
@@ -10,6 +10,7 @@ export type {
ModelBinding,
ModelCall,
ModelGateway,
ResolvedEndpoint,
ModelResult,
ModelUsage,
UpsertLlmInstance,
@@ -26,6 +26,25 @@ export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
const gateway: ModelGateway = {
mode: deps.mode,
/**
* 解绑定:名 → 四元(密钥当场解出)。给 app 层装 Mastra Agent 那种
* 需要真模型对象的场合(tool-calling 往返,`generate` 的 text-in/text-out 承载不了);
* `generate` 那条主路照旧全程在网关内部,录制回放不受影响。
*/
async resolve(modelName) {
const binding = await deps.instances.findByName(modelName);
if (binding === undefined) throw new ModelNotBoundError(modelName, [{ field: 'name', message: '实例不存在' }]);
if (!binding.enabled) throw new ModelDisabledError(modelName);
const violations = validateBinding(binding);
if (violations.length > 0) throw new ModelNotBoundError(modelName, violations);
return {
provider: binding.provider,
baseUrl: binding.baseUrl,
model: binding.model,
apiKey: deps.secrets.open(binding.apiKeyCipher!),
};
},
async generate(call: ModelCall): Promise<ModelResult> {
const key = replayKey(call);
+7 -1
View File
@@ -70,7 +70,13 @@ export type AgentAction =
/** 评估语义性前提(评估留痕,可回放可归因) */
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
/** 判定成果已齐,提交放行 */
| { readonly kind: 'submit'; readonly rationale: string };
| { readonly kind: 'submit'; readonly rationale: string }
/**
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
* 开环模式(Mastra Agent 自主连环调工具)专用;单拍模式给不出这一型。
*/
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
export type TurnOutcome = {
readonly runtime: CaseRuntime;
@@ -190,6 +190,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime, status: 'awaiting' };
}
case 'asked': {
/*
* 开环模式:调人已由**工具总线**在拍内执行(发卡 + 立监听),引擎只收尾。
* 再发一张就是重复调人 —— 工位上两张一模一样的卡,谁也不知道该答哪张。
*/
await deps.memory.append({
taskId,
kind: 'thought',
title: `本回合调人待回(卡 ${action.cardId})`,
detail: { rationale: action.rationale },
});
return { runtime, status: 'awaiting' };
}
case 'submit': {
// 终局唯放行:提交 = 发验收卡挂起等人裁决,不是自结(引擎不把案计划置为 Completed)
return raiseAcceptance(taskId, runtime, action.rationale);
+7
View File
@@ -22,6 +22,13 @@
app(治理台 · 工具管理)
不变量:
- **工具总线是声明式的**(2026-07-28):Agent 能调什么由**活动声明 × 工具目录**在
编译期定死(`compileToolBus`),不由 Agent 临场想、也不由活动代码自己决定;
声明了却查无此工具 / 已停用如实报,不静默给空壳。三路同一张清单:
`function`(功能工具,经工具调用一缝:白名单/录制回放/写类只登记意向)、
`human`(**人类工具**,系统内化、零声明即得 —— 调人是 Harness 给的能力)、
`llm`(**LLM 五件**:抽取/归类/摘要/裁断/改写,经模型网关一缝;
仅当活动声明了模型用途名才上总线 —— 没声明却会问模型是越权,用量也无从归因)
- **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10)
- `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError`
- **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域;
@@ -0,0 +1,74 @@
/**
* 工具总线的声明面 —— **Agent 能调什么由声明定死**,不由它临场想(domain 测试零 mock)。
*/
import { describe, expect, it } from 'vitest';
import { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
import type { CatalogEntry } from '../internal/domain/tool-bus.ts';
const entry = (over: Partial<CatalogEntry> & { name: string }): CatalogEntry => ({
description: '一件工具',
input: { type: 'object', properties: { pn: { type: 'string' } } },
effect: 'read',
...over,
});
const catalog = new Map<string, CatalogEntry>([
['crm.history-price-read', entry({ name: 'crm.history-price-read' })],
['mail.send', entry({ name: 'mail.send', effect: 'write' })],
['old.tool', entry({ name: 'old.tool', enabled: false })],
]);
describe('compileToolBus —— 声明 × 目录', () => {
it('只给声明过的功能工具;效应原样带出(写类下游不当场落地)', () => {
const report = compileToolBus({
declaredTools: ['crm.history-price-read', 'mail.send'],
catalog,
declaredModels: [],
});
const fn = report.tools.filter((t) => t.kind === 'function');
expect(fn.map((t) => `${t.id}:${t.effect}`)).toEqual(['crm.history-price-read:read', 'mail.send:write']);
expect(report.missing).toEqual([]);
});
it('没声明的工具不在总线上 —— 白名单之外结构上调不到', () => {
const report = compileToolBus({ declaredTools: ['crm.history-price-read'], catalog, declaredModels: [] });
expect(report.tools.some((t) => t.id === 'mail.send')).toBe(false);
});
it('声明了却查无此工具 / 已停用:如实报,不静默给一个空壳', () => {
const report = compileToolBus({
declaredTools: ['crm.history-price-read', 'ghost.tool', 'old.tool'],
catalog,
declaredModels: [],
});
expect(report.missing).toEqual(['ghost.tool']);
expect(report.disabled).toEqual(['old.tool']);
expect(report.tools.some((t) => t.id === 'ghost.tool' || t.id === 'old.tool')).toBe(false);
});
/* 人类工具是 Harness 给的能力,不是活动自带的本事 —— 每个活动天然可以调人 */
it('人类工具恒在:一件工具都没声明也能调人', () => {
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
expect(report.tools).toHaveLength(1);
expect(report.tools[0]).toEqual(HUMAN_TOOL);
expect(report.tools[0]!.kind).toBe('human');
});
/* 没声明模型的活动突然会「问模型」= 越权,且用量无从归因 */
it('LLM 五件只在活动声明了模型用途名时才上总线', () => {
const without = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
expect(without.tools.filter((t) => t.kind === 'llm')).toHaveLength(0);
const with_ = compileToolBus({ declaredTools: [], catalog, declaredModels: ['quote-extractor'] });
expect(with_.tools.filter((t) => t.kind === 'llm').map((t) => t.id)).toEqual(LLM_TOOLS.map((t) => t.id));
expect(with_.tools.filter((t) => t.kind === 'llm')).toHaveLength(5);
});
it('LLM 五件都是读类、都有入参契约 —— 模型照它填参', () => {
for (const tool of LLM_TOOLS) {
expect(tool.effect).toBe('read');
expect(tool.input['type']).toBe('object');
expect(Array.isArray(tool.input['required'])).toBe(true);
}
});
});
+3
View File
@@ -43,6 +43,9 @@ export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
export { createToolboxService } from './internal/usecase/toolbox-service.ts';
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
/* 工具总线的声明面(纯逻辑):声明 × 目录 → 一张清单;人类工具与 LLM 五件系统内化 */
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
@@ -0,0 +1,213 @@
/**
* 工具总线的**声明面** —— 纯逻辑,零 IO、零框架(编码规范 §5.3 / §6.2)。
*
* 「Agent 调用工具都是声明式的」:一个活动**能调什么**,不由 Agent 临场想、也不由
* 活动代码自己决定 —— 由活动清单的 `declarations` 与工具目录在**编译期**定死。
* 这里就是那次编译:声明 × 目录 → 一张总线清单(`BusTool[]`),
* 清单之外的调用在结构上不可能发生(白名单在 `ToolInvoker` 再把一次关)。
*
* 三路工具,同一张清单、同一套形状:
* · `function` —— 功能工具:第一方 / 外部,经**工具调用一缝**(录制回放、白名单、
* 写类先登记意向,放行时才落地);
* · `human` —— **人类工具**(系统内化,零声明即得):调人 = 发工单 + 立监听听人。
* 它是三型行动里的「调人」,不是外部工具,不需要注册也不进白名单;
* · `llm` —— **LLM 工具**(系统内化,五件通用):抽取 / 归类 / 摘要 / 裁断 / 改写,
* 经**模型网关一缝**(录制回放、四元绑定)。活动只声明用途名,不见厂商型号。
*
* 装配成具体可调用对象(Mastra `createTool`)是 app 层的事 —— 这里只出清单。
*/
/** 总线上的一件工具:形状与三路无关,调用路由靠 `kind` */
export type BusTool = {
readonly id: string;
readonly description: string;
/** 入参契约(JSON Schema)—— 装配层据此生成 zod,模型据此填参 */
readonly input: Record<string, unknown>;
readonly output?: Record<string, unknown>;
readonly kind: 'function' | 'human' | 'llm';
/**
* 效应:`read` 当场调当场回;`write` **不当场落地** ——
* 过程中只登记动作意向,放行那一刻才由系统执行(OS 06「过程不落地」)。
*/
readonly effect: 'read' | 'write';
};
/** 目录里的一条工具契约(取自 toolbox,只用得上这几个字段) */
export type CatalogEntry = {
readonly name: string;
readonly description: string;
readonly input: Record<string, unknown>;
readonly output?: Record<string, unknown>;
readonly effect: 'read' | 'write';
readonly enabled?: boolean;
};
/**
* **人类工具**(系统内化):调人。
*
* 「AI 对世界只有触达与等待两种姿势」—— 调人属于等待:发一张工单到负责人工位,
* 立一条监听听他的回传,本拍就此挂起。它不进工具目录、不需要白名单:
* 每个活动天然可以调人,这是 Harness 给的能力,不是活动自带的本事。
*/
export const HUMAN_TOOL: BusTool = {
id: 'human.ask',
description: '调人:把一件事交到负责人工位上问他(发工单 + 立监听),本回合就此等待回传',
input: {
type: 'object',
required: ['title', 'ask'],
properties: {
title: { type: 'string', description: '工单标题:一句话说清要他做什么' },
ask: { type: 'string', description: '问题正文:要他答什么、依据什么答' },
basis: {
type: 'array',
description: '依据快照:给他看的事实(键值对),不要让人自己去翻',
items: { type: 'object', properties: { k: { type: 'string' }, v: { type: 'string' } } },
},
fields: { type: 'array', description: '要他填的字段名', items: { type: 'string' } },
},
},
output: {
type: 'object',
properties: { asked: { type: 'boolean' }, cardId: { type: 'string' } },
},
kind: 'human',
effect: 'read',
};
/**
* **LLM 工具**(系统内化,五件通用):大模型驱动的通用能力,不是「一个模型实例」。
*
* 每件都是**有契约的工具**:入参出参定死、经模型网关一缝(录制回放可重放)、
* 用量计入任务预算。活动声明模型用途名(`declarations.models`),换档只改绑定。
* 五件之外要更专的能力,写成功能工具进目录 —— 不在这儿加。
*/
export const LLM_TOOLS: readonly BusTool[] = [
{
id: 'llm.extract',
description: '抽取:从自由文本里按给定字段抽出结构化事实(抽不到的字段留空,不编)',
input: {
type: 'object',
required: ['text', 'fields'],
properties: {
text: { type: 'string', description: '原文' },
fields: { type: 'array', items: { type: 'string' }, description: '要抽的字段名' },
hint: { type: 'string', description: '可选:字段含义与格式提示' },
},
},
output: { type: 'object', properties: { values: { type: 'object' } } },
kind: 'llm',
effect: 'read',
},
{
id: 'llm.classify',
description: '归类:把一段内容判进给定类目之一(类目必须穷举,判不了就回 unknown)',
input: {
type: 'object',
required: ['text', 'labels'],
properties: {
text: { type: 'string' },
labels: { type: 'array', items: { type: 'string' }, description: '候选类目(穷举)' },
hint: { type: 'string' },
},
},
output: { type: 'object', properties: { label: { type: 'string' }, rationale: { type: 'string' } } },
kind: 'llm',
effect: 'read',
},
{
id: 'llm.summarize',
description: '摘要:把长文压成要点(叙述是视图不是事实 —— 结论不入事实库)',
input: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string' }, maxWords: { type: 'integer' }, focus: { type: 'string' } },
},
output: { type: 'object', properties: { summary: { type: 'string' } } },
kind: 'llm',
effect: 'read',
},
{
id: 'llm.judge',
description: '裁断:对一个语义性判据给出是/否与理由(**只作参考**,不得代替人的放行裁决)',
input: {
type: 'object',
required: ['question', 'basis'],
properties: {
question: { type: 'string', description: '要判的问题(是非题)' },
basis: { type: 'string', description: '判据材料' },
},
},
output: {
type: 'object',
properties: { verdict: { type: 'boolean' }, confidence: { type: 'number' }, rationale: { type: 'string' } },
},
kind: 'llm',
effect: 'read',
},
{
id: 'llm.rewrite',
description: '改写:按语气/格式要求重写一段文字(只改表达,不添事实)',
input: {
type: 'object',
required: ['text', 'instruction'],
properties: { text: { type: 'string' }, instruction: { type: 'string' } },
},
output: { type: 'object', properties: { text: { type: 'string' } } },
kind: 'llm',
effect: 'read',
},
];
export type ToolBusInput = {
/** 活动清单声明的功能工具白名单(`declarations.tools[].name`) */
readonly declaredTools: readonly string[];
/** 工具目录:名 → 契约 */
readonly catalog: ReadonlyMap<string, CatalogEntry>;
/** 活动声明了模型用途名才给 LLM 工具 —— 没声明模型的活动不该会「问模型」 */
readonly declaredModels: readonly string[];
};
export type ToolBusReport = {
readonly tools: readonly BusTool[];
/** 声明了却在目录里查无此工具 / 已停用 —— 如实报,不静默吞 */
readonly missing: readonly string[];
readonly disabled: readonly string[];
};
/**
* 编译总线清单:**声明是唯一入口**。
*
* 功能工具按活动白名单逐条查目录(查不到 / 已停用如实报,不给它);
* 人类工具恒在(Harness 给的能力);LLM 五件仅当活动声明了模型用途名才给 ——
* 一个没声明模型的活动突然会「问模型」,那是越权,也让用量无从归因。
*/
export function compileToolBus(input: ToolBusInput): ToolBusReport {
const tools: BusTool[] = [];
const missing: string[] = [];
const disabled: string[] = [];
for (const name of input.declaredTools) {
const entry = input.catalog.get(name);
if (entry === undefined) {
missing.push(name);
continue;
}
if (entry.enabled === false) {
disabled.push(name);
continue;
}
tools.push({
id: entry.name,
description: entry.description,
input: entry.input,
...(entry.output === undefined ? {} : { output: entry.output }),
kind: 'function',
effect: entry.effect,
});
}
tools.push(HUMAN_TOOL);
if (input.declaredModels.length > 0) tools.push(...LLM_TOOLS);
return { tools, missing, disabled };
}