api-agent — Agent 编排服务契约
包名: @aalis/api-agent
源码: packages/api-agent/src/index.ts
实现: @aalis/plugin-agent
概述
定义对话编排服务 AgentService、描述符 agent、绑定接口 BoundAgent,以及一组 agent:* 钩子与 agent:prompt 贡献点。Agent 负责接收用户消息后完成"组装系统提示 → 加载历史 → 调用 LLM → 执行工具循环 → 发出回复"的完整流程。
服务接口
interface AgentService {
handleMessage(message: IncomingMessage): Promise<void>;
abort(
sessionId: string,
options?: { turnId?: string; queued?: boolean; reason?: string; by?: NonNullable<IncomingMessage['actor']> },
): boolean;
clearing(sessionIds: readonly string[] | undefined, clear: () => Promise<unknown>, reason?: string): Promise<void>;
registerPreprocessor?(name: string, handler: PreprocessorFn): () => void;
assignModel(sessionId: string, model: ModelRef): Promise<void>;
}对话调用经业务句柄 agent.use 转发给选中的那一家(agent.use.handleMessage(...))。Gateway 在 inbound:dispatch 相位调 handleMessage,业务层很少直接取。handleMessage 交一条入站消息,返回时这条输入已落定:它所在的一轮结束,或它被放掉(abort 连同排队项、停机、会话删除),或它没进一轮就被丢掉(agent:input:before 的中间件不调 next)。新消息不打断在跑的一轮:叠进由真人消息开的那一轮或排在它之后,带来源的注入各自成一轮,第一方实现的规则见 plugin-agent 的「新消息怎么处理」。
assignModel 把一个会话本会话层的对话模型设成 model(写进会话设置):子任务插件给子会话指定模型时,在建好之后、发首条消息之前调;会话设置不在或写入被拒时抛错。实现者必须提供它。
会话删除(session:deleted)时实现者停掉这个会话在跑的一轮、放掉排着的输入并等它收尾:会话管理在这个事件之后才清数据,不停的话删掉的会话里这一轮照样跑完、落库。还没交给通道的按代号分:handleMessage 入口记下这个会话的代号(api-session-manager 的 generationOf),交给通道之前(按住期间等着的放开之后)再取一次,变了的是删除开始之前已收录的旧一代,放掉、不归档。
abort 停下这个会话在跑的一轮,实现者必须提供它(停止键、/stop、禁言、后台页的「全部停止」都经它)。turnId 给了而对不上(那一轮已经结束或换成了新一轮)时什么也不停;queued 为真时连同排队与待插入、还没进一轮的输入一起放掉:它们不开轮,在这个会话在跑的一轮落定之后归档(不带回合 id),否则照常开下一轮;reason 只写进回合结束那一行日志,不进界面与回合结局;by 是停下这一轮的人,与入站的授权身份(IncomingMessage.actor)同一形状,不给即没有具体的人(停机、清历史、删会话、禁言等)。返回值是有没有停下在跑的一轮,调用方据此分辨「turnId 对不上」。
clearing 由清消息历史的一方调(第一方是 plugin-commands 的 /clear 与会话管理删会话),实现者必须提供它。sessionIds 不给即全部会话,clear 是整条清理:memory:clear 链连同默认动作,会话管理删会话时交来的先删各级子会话、发 session:deleted,再跑这条链。先停下这些会话在跑的一轮、放掉排着与待插入的输入(不归档),等它们落定再跑 clear;从停下到 clear 结束按住这些会话,这期间到的输入等它结束再处理(不开轮,不会回了话又被清掉)。停下与按住都在各家清理之外,不看它们的中间件登记先后。实现者自己的 session:deleted 监听在这次按住之内被调起,不能等按住放开,否则删会话一直挂住。reason 同 abort 的,只写进回合结束那一行日志。
事件 agent:queue({ sessionId, waiting })是这个会话排着与待插入、还没进一轮的输入(已过逐条处理,按入队先后),入队、进了一轮、被放掉时发。
回合 id
实现者在一轮开始时生成回合 id(turnId),这一轮的 agent:turn:before、agent:turn:after、输入的归档(见下一节)、流式片段(outbound:stream)、工具调用(ToolCallContext 与 tool:execute)、用量(token:usage)与这一轮发出的消息(回复与 [错误] 等提示的 OutgoingMessage.turnId)都带同一个 turnId。每次工具调用另有 callId,见 api-tools。回应 token:request 的用量快照不在任何回合里,不带 turnId。
落库与外发的约定
会话服务回看历史、在实时推送里标出「未发出」都依据下面几条,别的 agent 实现照此做,历史才与实时推送、与群里看到的一致:
- 元数据的键:落库的工具调用组(带
toolCalls的 assistant 与 tool 结果)与最终回复在元数据里记下这一轮的回合 id;每次工具调用(含没有执行就失败的)在 tool 结果的元数据与时间线的tool_call段记下这次调用的callId;最终回复另记这一轮的模型与 token、这一轮的平台与她在那里的身份、这一轮的群与会话类型,回复是结构化输出的信封时另记解码后的可见正文。键一律用 schema-message 登记的WellKnownMetadataKeys,各键见 schema-message 的「存档元数据的键」表里写方为 agent 的各项。agent:tool:after里别的插件写进metadata的键随 tool 结果原样落库。 - 房间里一轮只把最后一条回复交给平台:房间(会话种类为
room的 IM 群与私聊)里,一轮里工具调用之前说的话、格式校验没通过而重试丢掉的几版都不发出(不经outbound:message),只有最后一条回复发出。会话服务回看历史时据此把那几段标「未发出」;实时推送按平台的送达结果标,不受影响。 - 输入在进入一轮时归档:开轮的输入在
agent:turn:before的默认动作开头、插进来的在插入时经 message-archive 归档,archiveIncoming带这一轮的回合 id 与进入方式(开轮的为opened,插进来的为joined),存档记成登记的键TurnId、TurnEntry;排着、还没进一轮的不在历史里,被放掉的归档时不带回合 id。连对话回滚按回合 id 只删这一轮的消息,不写的话这一轮的输入回滚时删不掉。 - 每一轮写一条回合记录:在
agent:turn:after之前,把这一轮的结局与来源(schema-message 的TurnRecord,登记的键TurnRecord)记在最后落库的那条回复的元数据上;没有落库回复的一轮(静默、中止、出错)另落一条只带回合 id 与它的事件标记(role: 'system'、kind: 'event-marker'、正文为空)。在agent:turn:before被拦下的一轮不写。会话服务按进入方式与回合记录还原时间线;不写的话,历史里只少这一轮的结局(「没有回复」「已停止」等)与回合的来源。中止的一轮在历史里已有这一轮的输入或工具调用组时,参考实现在回合记录之前另落一条停下标记(role: 'system'、kind: 'turn-stopped',正文写给下一轮的模型,有abort交来的by时写是谁),会话服务不画它;别的实现不写时,下一轮的模型只看得到一串没有回复的输入。
用量(token:usage)
token:usage(TokenUsageEvent)是一次模型调用的输入用量。实现者在每次调用模型之后发一次:首次调用、工具循环里的每次调用、格式重试的每次调用,调用抛错的那次不发。回应 token:request 时按会话现组一份消息列表发快照,不调用模型,快照不带 turnId;按用量触发动作的消费方(如摘要插件的自动压缩)据此排除快照。
used与usageRatio:used取模型服务返回的这次调用的输入 token(ChatResponse.usage.promptTokens),没有返回或不是正的有限数时(以及快照)按发出的消息与工具定义估算;usageRatio是used / contextWindow。trimmed:本回合到这次调用为止经过了硬裁剪,即这次或之前某次调用组装后的估算超过了tokenBudget(不一定真裁掉了内容),或历史超过它的可用量、丢了一段(参考实现按 token 成段丢历史,整份请求放得下时也丢)。之后的调用在首次调用裁剪过的列表上追加,所以有过一次,本回合之后各次都算。untrimmedHistory:按没经过硬裁剪的消息列表估算的历史与工具结果 token;breakdown的history、toolResults两项按发出的列表算。裁掉的部分仍在会话历史里,全量压缩能把它们一并压掉,摘要插件以它为可压缩部分。historyMessages:会话历史的条数,即这一轮开头会话的活跃历史条数(含这一轮开轮的输入,也含条数线之外、没发给模型的部分),加本回合到这次调用为止追加的工具调用与结果(插进来的输入与它们一样按追加计)。只数对话消息,事件标记(schema-message 的CONTROL_KINDS)不算,与 api-memory 的countHistory同一口径。与硬裁剪无关;快照只有活跃历史条数。historyLimit:这一轮的历史条数上限(会话设置「历史条数上限」,0 为不按条数),由 agent 填:摘要插件按它判定按条数压缩,第三方 agent 不填时摘要的条数线静默失效。
breakdown 与 parts 是同一份输入按来源估算的拆分,各项之和不必等于 used。breakdown 是按固定桶的数,供 AI 自检(plugin-prompt-budget)判断;parts 是同一份数按显示的次序排好、带显示名的拆分表,不含为回复预留的 maxTokens,供界面照画:
interface TokenUsagePart {
label: string | TextRef; // 显示名
tokens: number;
injectors?: Record<string, number>; // 这一项按注入者的明细,键是注入者的全局键「插件实例 id/局部 id」
}会话服务把 parts 原样放进会话协议的 usage 条目,界面按条目画行、不认分项的键。别的实现照样给出自己的分项与显示名。
会话设置项
模型、深度思考、最大工具迭代与历史条数上限是 plugin-agent 的可按会话覆盖的配置字段,不在本契约里;别的插件要给一个会话指定模型调 assignModel。
绑定接口
interface BoundAgent extends AgentService {
registerPreprocessor(name: string, handler: PreprocessorFn): () => void;
}registerPreprocessor 走 registrar:同名替换,提供者换人自动重挂,随激活撤回。当前提供者不支持预处理器时本次不生效,换到支持的提供者时补上。不要对 AgentService.registerPreprocessor 自己传归属——门面已代填。
plugin-file-reader 只经预处理器处理文件附件,没有别的入口:agent 提供者不支持预处理器时,文件附件不被处理(不入库、不生成描述,read_uploaded_file 也找不到),插件不记日志。
预处理器
type PreprocessorFn = (message: IncomingMessage, next: () => Promise<void>) => Promise<void>;预处理器挂在 agent:input:before 上,每条输入一次,在它进入一轮之前。洋葱模型:调用 next() 把控制权交给下一个;不调用即丢掉这条,不进一轮、不归档。常见用法:把图片识别为文字、解析文件内容。
钩子(HookContextMap)
| 钩子 | 时机 | payload |
|---|---|---|
agent:input:before | 每条输入一次,在它进入一轮之前(预处理器挂在这里)。next 之前可以改写这条,不调 next 即丢掉它(不进一轮、不归档,agent 照发流结束标记);next 返回时这条已落定 | { message } |
agent:turn:before | 一轮开始。默认动作从按先后归档这一轮的输入开始,是这一轮的全部:中间件里输入还没归档(没有 archived),读历史也读不到它们。不调 next 即这一轮不跑、输入不归档、不发 agent:turn:after,实现者照发流结束标记 | { turnId, sessionId, inputs };inputs 是这一轮的输入(TurnInput[],见下文),同一轮的输入会话与来源相同;它是活的:插进来的输入在送进模型之前追加到末尾 |
agent:llm:before | 调用 LLM 之前(含工具调用后的下一次请求) | { messages, tools, acceptsImages, sessionId, source?, ... };acceptsImages 是这一轮的主模型能不能亲眼看图,与工具上下文的 acceptsImages 同一个值(媒体按它决定当轮附件交原图还是交描述)。第一方 agent 按模型服务给这个模型登记的能力填:端点报告了视觉能力,或在模型服务的「单模型能力覆盖」(modelCapabilities)里写了 vision;第三方 agent 要按同一事实填。source 与本回合 IncomingMessage.source 相同,真人消息缺省 |
agent:llm:after | LLM 返回之后 | { response, messages } |
agent:tool:before | 工具调用之前 | { name, args, toolCallContext } |
agent:tool:after | 工具调用之后;result 可以改写,写进 metadata 的键随这次工具结果落库(tool 消息的元数据,turnId、callId 由 agent 写),供回看历史的一方还原调用的结局 | { name, result, toolCallContext, metadata } |
agent:reply:before | 发出回复之前 | { content, archiveContent?, visibleContent?, sessionId, ... } |
agent:turn:after | 一轮处理完成(这一轮在 agent:turn:before 被拦下时不触发) | { inputs, reply, outcome, error?, sessionId, turnId };inputs 与 agent:turn:before 的同一份,error 是 string | TextRef |
一轮的输入是 TurnInput:
interface TurnInput {
readonly message: IncomingMessage; // 入站消息,经过了预处理器
archived?: Message; // 归档进会话历史的那一条,归档之后才有;不归档的(空闲开话题)与没有存档插件时一直没有
}这些键经 declaration merging 注入 @aalis/api-hooks 的 HookContextMap。钩子用 hooks.use.middleware 注册,hooks 描述符来自 @aalis/api-hooks(见 api-hooks)。要让 TS 看到这些键,需把 @aalis/api-agent 加进依赖(值导入或 side-effect import)。
提示词注入不在这条链上:摘要、语义记忆、档案、技能等走 agent:prompt 贡献点(contributions.use.contribute('agent:prompt', spec),契约见 api-contributions),由 plugin-agent 的组装器在 agent:llm:before 之前统一物化。贡献的 build(view) 拿到的视图(PromptContributionView)有会话 id、平台、这一轮的输入 inputs(与 agent:turn:before 的同一份,已归档的带 archived;回应 token:request 的估算为空)、中止信号、干跑标记与消息列表的快照;按发言者取材的贡献(档案、关系图)看各条输入的发言者与 triggerType,按这一轮内容检索的(语义记忆)用各条的 archived 正文。
典型用法
import { agent } from '@aalis/api-agent';
import { hooks } from '@aalis/api-hooks';
import { definePlugin, optional } from '@aalis/core';
export default definePlugin({
name: '@acme/plugin-example-agent',
uses: { agent: optional(agent), hooks },
apply({ agent, hooks }) {
agent.use.registerPreprocessor('suffix', async (message, next) => {
message.content += ' [logged]';
await next();
});
hooks.use.middleware('agent:turn:after', async (data, next) => {
await next();
});
},
});实现者列表
- @aalis/plugin-agent —— 默认实现,含 12 桶 token 预算追踪与工具循环
相关
IncomingMessage/Message定义在 schema-messageagent:turn:after的outcome字段是replied | silent | aborted | error(四条退出路径都会触发,供生命周期订阅方收口);结局为replied时最终回复已写进会话历史,它在最后一次模型调用之后落库,不在那次用量的historyMessages里,其余结局不落库最终回复;结局为error时error是一句原因(默认实现填发到会话的[错误]那句,或解析不出模型时[系统]那句),会话管理记进SessionInfo.lastTurn,其余结局不带- token 自检见 plugin-prompt-budget
- 关停时默认实现在
lifecycle.use.onDrain中止在飞回合,见 services/agent