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
165 lines
6.8 KiB
TypeScript
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',
|
|
};
|
|
}
|
|
}
|