Files
JX-BPOS/apps/api/src/app/agent-runner.ts
T
ChaiHaoandClaude Opus 5 c68a562db1 loop:模型问不出结果时转人裁断,而不是抛异常空转
A3 / A2 起来后每一拍都抛 `Authentication Fails` 并被推进器吞掉:
任务永远 running、预算照烧、负责人工作台上什么也看不到。

模型这一轮给不出行动(端点不可达 / 鉴权失败 / 超时),**不是异常路径,是断点的一种**:
机器当下决断不了就该调人 ——「人在环是一种被调用的工具」。世界一步都没碰,
交回给人是安全且诚实的落点。改在 app 的 agent-runner 收口(引擎保持纯粹),
与 flow 的步级 `failure.fallback` 同构。

缺口作为**依据**摆在卡上,不当表单项让人逐条填。

现状:A3 渠道寻源(林佩萱)装载 21 个计划项、A2 替代选型(王梓名)装载 19 个,
两个 loop 都在等人回卡,不再空转。

gates: tsc / eslint / depcruise / 393 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 04:57:16 +08:00

165 lines
6.8 KiB
TypeScript

/**
* loop 的 Agent 回路 —— `AgentPort` 的实现(app 层装配,编码规范 §5.2)。
*
* 「回合由 usecase 驱动,**不放开自由 maxSteps**,预算在环外由 Harness 强制」——
* 所以这里只做**一次**决断:把 Enabled 集与红绿灯喂给模型,要它在三型行动里选一个。
*
* 一切模型调用经 `ModelGateway`(两缝之一),replay 模式下自动走录制 —— 回放审看得见。
* 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway,
* 端口不变,换实现时 usecase 不动。
*/
import type { AgentAction, EnabledSet } from '@modules/loop-engine';
import type { ModelGateway } from '@modules/llm';
import type { TaskId } from '@contracts';
/** Agent 只能选三型行动之一(外加规划 / 弃用 / 评估 / 提交)—— schema 把它框住 */
const ACTION_SCHEMA = {
type: 'object',
required: ['kind'],
properties: {
kind: { enum: ['start', 'plan', 'disable', 'judge', 'tool', 'human', 'listen', 'submit'] },
itemId: { type: 'string' },
tool: { type: 'string' },
input: { type: 'object' },
workOrder: { enum: ['execution', 'acceptance'] },
card: { type: 'object' },
eventType: { enum: ['data', 'time'] },
keyFilter: { type: 'object' },
deadline: { type: 'string' },
criterion: { type: 'string' },
satisfied: { type: 'boolean' },
rationale: { type: 'string' },
reason: { type: 'string' },
},
} as const;
export type AgentRunnerDeps = {
readonly gateway: ModelGateway;
/** 活动声明的自定义模型名 */
readonly modelOf: (taskId: TaskId) => Promise<string>;
};
export function createAgentRunner(deps: AgentRunnerDeps): {
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
} {
return {
async decide({ taskId, narrative, enabled, scope }) {
// 交集为空即断点 —— 引擎已经算好了,这里只把它讲给模型听
const breakpoint = enabled.entries.length === 0;
const prompt = [
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
'你只能选一个行动,且必须落在三型行动之内:调工具 / 调人 / 立监听(外加 start、plan、disable、judge、submit)。',
'规矩:①触达世界的动作只能 tool(写类会被登记为动作意向,放行时才落地)',
'②缺口只有人知道 → human;缺口在世界侧 → listen(必须给 deadline,ISO-8601)',
'③成果已齐才 submit。只输出 JSON。',
'',
`【案例说明书与活动记忆】\n${narrative}`,
'',
`【当下可为(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')}`,
breakpoint ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,请调人或立监听。' : '',
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
]
.filter((line) => line !== '')
.join('\n');
const model = await deps.modelOf(taskId);
try {
const result = await deps.gateway.generate({
model,
prompt,
outputSchema: ACTION_SCHEMA,
taskId,
label: 'loop-turn',
});
return normalize(result.object, enabled);
} catch (error) {
/*
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
*
* 这**不是**异常路径,是断点的一种:机器当下决断不了,就该调人 ——
* 「人在环是一种被调用的工具」。抛出去只会让任务挂着空转:
* 推进器每一拍都重试、每一拍都记一行错,预算照烧,负责人却看不到任何东西。
* 世界一步都没碰,交回给人是安全且诚实的落点。
*/
const detail = error instanceof Error ? error.message : String(error);
return {
kind: 'human',
itemId: enabled.entries[0]?.id ?? 'breakpoint',
workOrder: 'execution',
card: {
title: '模型这一轮未能决断,转人裁断',
reason: detail,
// 缺口是**依据**不是表单项:摆给人看,别让人逐条填
gaps: enabled.gaps.join('、') || '(无)',
enabled: enabled.entries.map((e) => `${e.id} ${e.name}`).join('、') || '(空)',
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
fields: ['decision'],
},
};
}
},
};
}
/** 模型出参归一为 AgentAction;不合规一律退化为「立监听等人」而不是乱动世界 */
function normalize(object: unknown, enabled: EnabledSet): AgentAction {
const raw = (object ?? {}) as Record<string, unknown>;
const kind = String(raw['kind'] ?? '');
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
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: String(raw['rationale'] ?? ''),
};
case 'tool':
return {
kind: 'tool',
itemId,
tool: String(raw['tool'] ?? ''),
input: (raw['input'] ?? {}) as Record<string, unknown>,
};
case 'human':
return {
kind: 'human',
itemId,
card: (raw['card'] ?? {}) as Record<string, unknown>,
workOrder: raw['workOrder'] === 'acceptance' ? 'acceptance' : 'execution',
};
case 'listen':
return {
kind: 'listen',
itemId,
eventType: raw['eventType'] === 'time' ? 'time' : 'data',
keyFilter: (raw['keyFilter'] ?? {}) as Record<string, string>,
// 时限必配 —— 模型没给就按三天兜底,绝不留幽灵等待
deadline: typeof raw['deadline'] === 'string' && /^P/.test(raw['deadline']) ? raw['deadline'] : 'P3D',
};
case 'submit':
return { kind: 'submit', rationale: String(raw['rationale'] ?? '判据已收敛') };
default:
// 看不懂就等人,不擅自触达世界
return {
kind: 'human',
itemId,
card: { title: '需要你判断下一步', reason: '模型未给出合规的行动' },
workOrder: 'execution',
};
}
}