工具总线 + 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
+197
View File
@@ -0,0 +1,197 @@
/**
* loop 的 Agent 回路(**开环**)—— `AgentPort` 的 Mastra 实现(M5-2;app 层装配 §5.2)。
*
* 与单拍版(`agent-runner.ts`)的分工:
* · 单拍版:问一次模型、要一个行动,引擎替它执行 —— 保守、每步可拦;
* · **开环版(此处)**:给它一张**工具总线**,让它在一拍内自主连环调工具
* (读类真调、写类登记意向、LLM 五件、调人),拍末只交回**这一拍的结论**。
*
* 放开的是工具循环,**不是规矩**:
* · 能调什么由活动声明定死(工具总线在编译期出清单,白名单在缝里再把一次关);
* · 写类工具**过程中一律不落地**,只登记动作意向 —— 放行那一刻才由系统执行;
* · 调人由总线执行(发卡 + 立监听),引擎收到 `asked` 就把这一拍挂起,不重复发卡;
* · 终局唯放行:Agent 只能 `submit`(提交放行),不能自己宣布完成;
* · 用量按 Mastra 回报的 usage 计入任务预算 —— 不计,预算熔断永远不会响。
*
* 代价(如实记 debt M5-2):开环下模型的**逐轮**调用不经模型网关的录制回放
* (tool-calling 往返 `generate` 的 text-in/text-out 抽象承载不了)——
* 四元绑定与凭证隔离照旧走网关 `resolve`;**工具调用一缝完整**,工具侧录制回放不破。
*/
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { Agent } from '@mastra/core/agent';
import { z } from 'zod';
import type { TaskId } from '@contracts';
import type { AgentAction, EnabledSet } from '@modules/loop-engine';
import type { ModelGateway } from '@modules/llm';
import type { BusTool } from '@modules/toolbox';
import { buildMastraTools } from './tool-bus.ts';
import type { ToolBusContext, ToolBusPorts } from './tool-bus.ts';
/** 拍末结论 —— Agent 干完活要说清「这一拍算什么」,不能只是聊完就走 */
const TURN_RESULT = z.object({
kind: z.enum(['start', 'plan', 'disable', 'judge', 'submit', 'idle']).describe('这一拍的结论'),
itemId: z.string().optional().describe('start / plan / disable 针对哪个计划项'),
reason: z.string().optional(),
criterion: z.string().optional().describe('judge:评估的是哪条语义性前提'),
satisfied: z.boolean().optional().describe('judge:满足与否'),
rationale: z.string().optional().describe('结论理由 —— 留痕要看,写实'),
});
export type MastraAgentRunnerDeps = {
readonly gateway: ModelGateway;
/** 活动声明的自定义模型名 */
readonly modelOf: (taskId: TaskId) => Promise<string>;
/** 这一单能调什么:工具总线清单(声明 × 目录编译而来) */
readonly busOf: (taskId: TaskId) => Promise<readonly BusTool[]>;
/** 三路缝 */
readonly ports: ToolBusPorts;
/** 拍内上下文:负责人与人工调用上限 */
readonly contextOf: (taskId: TaskId) => Promise<Omit<ToolBusContext, 'model'>>;
/** token 回报:计入任务预算 */
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
/** 一拍内最多几轮工具调用 —— 环外还有 Harness 的预算硬闸,这只是拍内的礼貌上限 */
readonly maxToolRounds?: number;
};
export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
} {
return {
async decide({ taskId, narrative, enabled, scope }) {
const modelName = await deps.modelOf(taskId);
const base = await deps.contextOf(taskId);
const bus = await deps.busOf(taskId);
const trace: { askedCardId?: string } = {};
const instructions = [
'你是这一单任务的案例工作者。你有一组工具可用,可以连续调用它们把这一拍的活干完。',
'',
'规矩(硬约束,违反即被系统拦下):',
'① **写类工具不会当场生效** —— 调了只是登记动作意向,放行那一刻系统才真执行。',
' 所以不要假设世界已经改变,更不要因为「已经调过」就跳过必要的确认。',
'② **调人(human.ask)会让这一拍就此挂起等回传** —— 只在真需要人裁断/补料时调,',
' 而且必须把卡填实:title 一句话说清要什么、ask 写明具体问题、basis 给出判断依据。',
' 一张只写「请处理」的卡等于白发。调完人就结束这一拍,别再调别的。',
'③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。',
'④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。',
'',
`【案例说明书与记忆】\n${narrative}`,
].join('\n');
const endpoint = await deps.gateway.resolve(modelName);
const provider = createOpenAICompatible({
name: endpoint.provider,
apiKey: endpoint.apiKey,
baseURL: endpoint.baseUrl,
});
const agent = new Agent({
id: `loop-${taskId}`,
name: '案例工作者',
instructions,
model: provider.chatModel(endpoint.model),
tools: buildMastraTools(bus, deps.ports, { ...base, model: modelName }, trace),
});
const prompt = [
`【当下可为(Enabled 集)】\n${
enabled.entries
.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`)
.join('\n') || '(空)'
}`,
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
enabled.judgements.length === 0
? ''
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
enabled.entries.length === 0 ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,调人或说清卡在哪。' : '',
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
'',
'干这一拍该干的活(可以连续调工具),最后给出结论。',
]
.filter((line) => line !== '')
.join('\n');
try {
const result = await agent.generate(prompt, {
maxSteps: deps.maxToolRounds ?? 8,
structuredOutput: { schema: TURN_RESULT },
} as never);
const usage = (result as { usage?: { totalTokens?: number; inputTokens?: number; outputTokens?: number } }).usage;
const tokens = usage?.totalTokens ?? (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
if (tokens > 0) await deps.onUsage(taskId, tokens);
// 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人)
if (trace.askedCardId !== undefined) {
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' };
}
return normalizeTurn((result as { object?: unknown }).object, enabled);
} catch (error) {
/*
* 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)——
* **不是异常路径,是断点的一种**:机器当下决断不了就该调人。
* 抛出去只会让任务空转:推进器每一拍重试、预算照烧,负责人却什么都看不到。
*
* 但**已经调过人**的那一拍例外:卡在世界上了,老老实实收成 asked,
* 不要再叠一张「模型出错」的卡上去。
*/
if (trace.askedCardId !== undefined) {
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' };
}
const detail = error instanceof Error ? error.message : String(error);
return {
kind: 'human',
itemId: enabled.entries[0]?.id ?? 'breakpoint',
workOrder: 'execution',
card: {
title: '模型这一轮未能决断,转人裁断',
reason: detail,
basis: [
{ k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' },
{ k: '目标缺口', v: enabled.gaps.join('、') || '(无)' },
],
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
fields: ['decision'],
},
};
}
},
};
}
/** 拍末结论归一为 AgentAction;给不出结论就当空拍(idle → judge 留痕),绝不乱动世界 */
function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>;
const kind = String(raw['kind'] ?? 'idle');
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
const rationale = String(raw['rationale'] ?? '');
switch (kind) {
case 'start':
case 'plan':
return { kind: kind as 'start' | 'plan', itemId };
case 'disable':
return { kind: 'disable', itemId, reason: String(raw['reason'] ?? '此单不走这条') };
case 'judge':
return {
kind: 'judge',
criterion: String(raw['criterion'] ?? ''),
satisfied: raw['satisfied'] === true,
rationale,
};
case 'submit':
return { kind: 'submit', rationale: rationale === '' ? '判定成果已齐' : rationale };
default:
/*
* idle:这一拍干了活(工具调用已经落在总线上)但推不动局面。
* 记成一条 judge 留痕 —— 空拍也要说清卡在哪,不然人看不见环在原地打转。
*/
return {
kind: 'judge',
criterion: '本回合是否推动了局面',
satisfied: false,
rationale: rationale === '' ? '模型未给出结论' : rationale,
};
}
}
+219
View File
@@ -0,0 +1,219 @@
/**
* 工具总线的**装配面** —— 声明清单 → 可调用的 Mastra tools(编码规范 §6.2:
* `@mastra` 只准出现在 adapter 与 app)。
*
* 声明面(纯逻辑,`toolbox/internal/domain/tool-bus.ts`)决定**能调什么**;
* 这里决定**怎么调** —— 三路各自穿过它该穿的那道缝,一道都不许绕:
* · `function` → **工具调用一缝**(`ToolInvoker`):白名单、录制回放、契约校验;
* 写类**不当场落地**,只登记动作意向,放行那一刻才由系统执行;
* · `human` → 人类工具:发工单到负责人工位 + 立监听听回传,本回合就此等待;
* · `llm` → **模型网关一缝**(`ModelGateway`):四元绑定、录制回放、用量计预算。
*
* 每件工具的入参 schema 由清单里的 JSON Schema 现译成 zod —— 模型照它填参,
* 填错当场被 Mastra 拦下,不会带着脏参数穿过缝。
*/
import { createTool } from '@mastra/core/tools';
import type { ToolsInput } from '@mastra/core/agent';
import { z } from 'zod';
import type { StaffId, TaskId } from '@contracts';
import type { BusTool } from '@modules/toolbox';
/** 三路缝的最小口子 —— 各自只要这一点点能力,不把整个服务拖进来 */
export type ToolBusPorts = {
/** 功能工具:读类当场调;写类先登记意向(放行才落地) */
readonly invoke: (input: {
taskId: TaskId;
tool: string;
input: Record<string, unknown>;
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { message: string } }>;
readonly isWriteEffect: (tool: string) => Promise<boolean>;
readonly registerIntent: (input: {
taskId: TaskId;
tool: string;
input: Record<string, unknown>;
label: string;
}) => Promise<void>;
/** 人类工具:发工单 */
readonly issueCard: (input: {
taskId: TaskId;
ownerId: StaffId;
humanCallCap: number;
workOrder: 'execution' | 'acceptance';
card: Record<string, unknown>;
}) => Promise<{ id: string }>;
/** 人类工具:立监听听人 */
readonly listen: (input: {
taskId: TaskId;
eventType: 'data' | 'time' | 'human';
keyFilter: Record<string, string>;
deadline: string;
}) => Promise<{ id: string }>;
/** LLM 工具:经模型网关 */
readonly generate: (input: {
model: string;
system?: string;
prompt: string;
outputSchema?: Record<string, unknown>;
taskId: TaskId;
label: string;
}) => Promise<{ text: string; object?: unknown }>;
};
export type ToolBusContext = {
readonly taskId: TaskId;
readonly ownerId: StaffId;
readonly humanCallCap: number;
/** 活动声明的模型用途名(第一个)—— LLM 工具用它,活动不见厂商型号 */
readonly model: string;
};
/** 本回合调人的痕:总线把它交回引擎,引擎据此把这一拍收成 awaiting */
export type ToolBusTrace = {
/** 调人发出的卡(有值即本回合要等人) */
askedCardId?: string;
};
/**
* JSON Schema → zod(仅译总线用得上的那一小撮:object/string/number/integer/
* boolean/array/enum)。译不出的类型退化成 `z.unknown()` —— 宁可放宽,
* 也不假装校验过:真正的契约校验在缝里(`ToolInvoker.validate`),这里只为让模型会填。
*/
function toZod(schema: Record<string, unknown> | undefined): z.ZodTypeAny {
if (schema === undefined) return z.object({}).passthrough();
const type = schema['type'];
const describe = (t: z.ZodTypeAny) =>
typeof schema['description'] === 'string' ? t.describe(schema['description']) : t;
if (Array.isArray(schema['enum'])) {
const values = (schema['enum'] as unknown[]).map(String);
return describe(values.length > 0 ? z.enum(values as [string, ...string[]]) : z.string());
}
if (type === 'string') return describe(z.string());
if (type === 'number') return describe(z.number());
if (type === 'integer') return describe(z.number().int());
if (type === 'boolean') return describe(z.boolean());
if (type === 'array') {
const items = schema['items'];
return describe(z.array(toZod(typeof items === 'object' && items !== null ? (items as Record<string, unknown>) : undefined)));
}
if (type === 'object') {
const props = schema['properties'];
if (typeof props !== 'object' || props === null) return describe(z.object({}).passthrough());
const required = new Set(Array.isArray(schema['required']) ? (schema['required'] as string[]) : []);
const shape: Record<string, z.ZodTypeAny> = {};
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
const field = toZod(typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined);
shape[key] = required.has(key) ? field : field.optional();
}
return describe(z.object(shape));
}
return describe(z.unknown());
}
/** LLM 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */
function llmPrompt(id: string, args: Record<string, unknown>): { system: string; prompt: string } {
const j = (v: unknown) => JSON.stringify(v ?? null);
switch (id) {
case 'llm.extract':
return {
system: '你是抽取器。只从原文里抽,抽不到的字段留空 —— **不许编**。只回 JSON。',
prompt: `字段:${j(args['fields'])}\n提示:${String(args['hint'] ?? '无')}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"values": {字段: 值}}`,
};
case 'llm.classify':
return {
system: '你是归类器。只能从给定类目里选一个;判不了就回 "unknown"。只回 JSON。',
prompt: `类目:${j(args['labels'])}\n提示:${String(args['hint'] ?? '无')}\n内容:\n${String(args['text'] ?? '')}\n\n回 {"label": "...", "rationale": "一句话理由"}`,
};
case 'llm.summarize':
return {
system: '你是摘要器。只压缩已有内容,不添事实、不作判断。只回 JSON。',
prompt: `重点:${String(args['focus'] ?? '无')}\n字数上限:${String(args['maxWords'] ?? 200)}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"summary": "..."}`,
};
case 'llm.judge':
return {
system: '你是裁断器。给出是/否与理由,并诚实给出把握度(0-1)。**你的结论只作参考,不代替人的放行裁决**。只回 JSON。',
prompt: `问题:${String(args['question'] ?? '')}\n判据:\n${String(args['basis'] ?? '')}\n\n回 {"verdict": true/false, "confidence": 0-1, "rationale": "理由"}`,
};
default:
return {
system: '你是改写器。只改表达方式,**不添事实、不删事实**。只回 JSON。',
prompt: `要求:${String(args['instruction'] ?? '')}\n原文:\n${String(args['text'] ?? '')}\n\n回 {"text": "改写后"}`,
};
}
}
/**
* 把总线清单装配成 Mastra tools。
*
* `trace` 是回传口:调人这件事在 Agent 眼里是「调了个工具」,在引擎眼里是
* 「本回合要挂起等人」—— 工具执行时把卡号记进 trace,引擎读它收尾。
*/
export function buildMastraTools(
bus: readonly BusTool[],
ports: ToolBusPorts,
context: ToolBusContext,
trace: ToolBusTrace,
): ToolsInput {
const out: ToolsInput = {};
for (const tool of bus) {
// Mastra 的 tool id 不收点号以外的怪字符,统一把点号换成双下划线,回调时换回来
const key = tool.id.replace(/[^a-zA-Z0-9_]/g, '__');
out[key] = createTool({
id: key,
description: tool.description,
inputSchema: toZod(tool.input) as never,
execute: async (inputData: unknown) => {
const args = (inputData ?? {}) as Record<string, unknown>;
if (tool.kind === 'human') {
const card = await ports.issueCard({
taskId: context.taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: 'execution',
card: args,
});
await ports.listen({
taskId: context.taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
trace.askedCardId = card.id;
return { asked: true, cardId: card.id };
}
if (tool.kind === 'llm') {
const { system, prompt } = llmPrompt(tool.id, args);
const result = await ports.generate({
model: context.model,
system,
prompt,
...(tool.output === undefined ? {} : { outputSchema: tool.output }),
taskId: context.taskId,
label: tool.id,
});
return (result.object ?? { text: result.text }) as Record<string, unknown>;
}
/*
* 功能工具:**写类不当场落地**(OS 06「过程不落地」)——
* 过程中只冻结入参登记为动作意向,放行那一刻由系统执行。
* 骗 Agent 说「已执行」是最坏的选项:它会据此往下推,而世界上什么都没发生。
*/
if (await ports.isWriteEffect(tool.id)) {
await ports.registerIntent({ taskId: context.taskId, tool: tool.id, input: args, label: tool.id });
return { registered: true, note: '写类工具已登记为动作意向 —— 放行时才真正执行,现在世界上什么都没变' };
}
const result = await ports.invoke({ taskId: context.taskId, tool: tool.id, input: args });
if (!result.ok) return { ok: false, error: result.error?.message ?? '工具调用失败' };
return { ok: true, ...(result.output ?? {}) };
},
});
}
return out;
}
+84 -10
View File
@@ -9,6 +9,7 @@ import type { EventService } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { MemoryService } from '@modules/memory';
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
import { compileToolBus } from '@modules/toolbox';
import type { HumanToolService } from '@modules/human-tool';
import { renderView } from '@modules/interaction';
import type { InteractionService } from '@modules/interaction';
@@ -33,6 +34,7 @@ import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sf
import type { StaffId, TaskId } from '@contracts';
import { staffIdSchema } from '@contracts';
import { createAgentRunner } from '../agent-runner.ts';
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
import type { Clock, IdGenerator } from '../runtime.ts';
export type EngineWiringDeps = {
@@ -398,21 +400,93 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
clock,
});
/* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */
/** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */
const modelOf = async (taskId: TaskId) => {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.models[0] ?? 'default';
};
const onAgentUsage = (taskId: TaskId, tokens: number) =>
taskStore.addConsumption(taskId, { tokens }).then(() => undefined);
/**
* 工具总线:**声明 × 目录**,编译期定死这一单能调什么(声明面在 toolbox/domain)。
* 功能工具按活动白名单查目录;人类工具恒在(Harness 给的能力);
* LLM 五件仅当活动声明了模型用途名才给 —— 没声明模型却会问模型,是越权也无从归因。
*/
const busOf = async (taskId: TaskId) => {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
const catalog = new Map(
(await deps.toolbox.list()).map((c) => [
c.name,
{
name: c.name,
description: c.description,
input: c.input,
...(c.output === undefined ? {} : { output: c.output }),
effect: c.effect === 'read' ? ('read' as const) : ('write' as const),
},
]),
);
const report = compileToolBus({
declaredTools: manifest.declarations.tools.map((t) => t.name),
catalog,
declaredModels: manifest.declarations.models,
});
// 声明了却查无此工具 / 已停用:如实记痕,不静默 —— Agent 手上少了件东西,得看得见
if (report.missing.length > 0 || report.disabled.length > 0) {
await memoryRecorder.append({
taskId,
kind: 'observation',
title: `工具总线缺件:未装 ${report.missing.join('、') || '无'};已停用 ${report.disabled.join('、') || '无'}`,
} as never);
}
return report.tools;
};
/*
* 开环 or 单拍:`JCAI_AGENT_OPEN_LOOP=1` 时用 Mastra Agent 放开拍内工具循环。
* 缺省仍是单拍版 —— 开环把「一拍调几次模型」交给了模型自己,
* 得有第一个真 loop 活动压着测过才好当缺省(见 debt M5-2)。
*/
const agentRunner =
globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1'
? createMastraAgentRunner({
gateway: deps.modelGateway,
modelOf,
busOf,
onUsage: onAgentUsage,
contextOf: async (taskId) => {
const ctx = await taskContext.of(taskId);
return { taskId, ownerId: ctx.ownerId, humanCallCap: ctx.humanCallCap };
},
ports: {
invoke: async (input) => {
const { activityId } = await taskContext.of(input.taskId);
return toolGateway.invoke({ ...input, activityId });
},
isWriteEffect: (tool) => toolGateway.isWriteEffect(tool),
registerIntent: (input) => toolGateway.registerIntent(input),
issueCard: (input) => cards.issue(input),
listen: (input) => listeners.register(input),
generate: async (input) => {
const result = await deps.modelGateway.generate(input);
await onAgentUsage(input.taskId, result.usage.inputTokens + result.usage.outputTokens);
return { text: result.text, ...(result.object === undefined ? {} : { object: result.object }) };
},
},
})
: createAgentRunner({ gateway: deps.modelGateway, onUsage: onAgentUsage, modelOf });
/* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({
state: createLoopStateStore(db),
sfeel,
data: taskData,
agent: createAgentRunner({
gateway: deps.modelGateway,
// loop 的每一回合都要问模型 —— 不计 token,预算熔断就永远不会响
onUsage: (taskId, tokens) => taskStore.addConsumption(taskId, { tokens }).then(() => undefined),
modelOf: async (taskId) => {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.models[0] ?? 'default';
},
}),
agent: agentRunner,
tools: toolGateway,
cards,
listeners,