@sema-agent/server 7.26.0 → 7.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,710 @@
1
+ /**
2
+ * DESIGN-269 §3 车2 —— **server-as-peer** 半场:这台 sema-server 被别的 orchestrator 当成一个 A2A agent 调用。
3
+ *
4
+ * 两条路由,**刻意分挂在服务凭据门的两侧**(clay 终裁 R-11,2026-08-16):
5
+ * · `GET /.well-known/agent-card.json` —— 门**外**。公网匿名可发现:一张卡就是「这里有一个 agent、
6
+ * 它叫什么、能干什么、往哪儿调」,没有它对端连协商都开始不了。卡上**零存量信息**(没有任何 task /
7
+ * run / session 面),所以匿名可读不构成披露。
8
+ * · `POST /v1/a2a` —— 门**内**。JSON-RPC 调用面,吃与本仓其余 API 同一套服务凭据(`headers` 形),
9
+ * 与我们 client 腿发出去的鉴权形对称。
10
+ *
11
+ * 总旋钮 `A2A_SERVE_ENABLED` 默认 **OFF**,OFF ⇒ 两条路由**都** 404(`config.a2aServe === undefined`
12
+ * 即 OFF —— 缺席即关,见 config-types 的 {@link import("../../config-types.js").A2aServeConfig})。
13
+ * 「不是发卡但拒调」是设计稿 §3.3 的原话:对外不存在就该整个不存在。
14
+ *
15
+ * ## 演化槽(设计稿 §4,本模块吸收其中三个)
16
+ * · **版本槽**:读请求 `a2a-version` 头 —— 认识与否都照常答,响应恒带自己的版本头。版本头是**诊断材料**
17
+ * 不是拒绝闸(开集容忍);真不兼容会在方法/形状层自然显形为具名错误。
18
+ * · **方法槽**:分派器是一张 {@link A2A_METHODS} 方法表,新方法=加一行;未知方法恒 `-32601`。
19
+ * 这是**协议内置的能力协商机制** —— 调用方按码降级,我们不自铸能力表。
20
+ * · **词表槽**:任务态只发 core `A2A_TASK_STATES` 九词闭集(我们是铸点)。
21
+ *
22
+ * ## v1 明确不做(成文定界,防镀金 —— 设计稿 §3 尾)
23
+ * · `message/stream`(SSE 腿)、push notification —— 未实现方法走 -32601,调用方按协议降级到轮询。
24
+ * · `tasks/cancel` —— v2 映射到既有 cancel 链(`POST /v1/runs/:id/cancel`);v1 不做半吊子取消。
25
+ * · card `securitySchemes` —— 我们的鉴权是**头形**,卡上如实不声明 OAuth 类 scheme,不假装。
26
+ * · file / data part 的**消费** —— v1 只收 text part,其余 part **具名拒绝**(-32005),不静默丢:
27
+ * 静默丢一个附件等于把「我没看见你给的文件」渲染成「我看过了」。
28
+ * · 批量(数组形)JSON-RPC —— 一个批里半数成功半数失败的**记账**语义要单独想清楚,先具名拒。
29
+ *
30
+ * ## 🔴 id 域的定谳(施工期裁决,与设计稿 §3.2 的措辞有偏离,理由成文于此)
31
+ * 设计稿 §3.2 写的是「`canAccessAgentRecord` 门后 `toA2ATaskState(record)` 投影」,那三只 core 原语
32
+ * 的入参是 **`BackgroundAgentRecord`**(`a*` 句柄域 = 一次 run **内部**派生的后台子 agent 行)。而
33
+ * 本车的硬约束是「`message/send` 必须复用既有提交链、不另铸提交路径」——既有提交链(`prepareSpec`
34
+ * → `createDurableRun`)产出的是一条 **run 行**(`RunRecord`,uuidv7 taskId),它**永远不会**是一条
35
+ * `BackgroundAgentRecord`(那是 core 在 run 内部为子 agent 铸的行,server 没有直接铸它的口)。两者的
36
+ * status 词表也不同(run:`running|suspended|needs_review|completed|failed|blocked`;bg agent:
37
+ * `running|parked|completed|failed|killed`),`toA2ATaskState` 的穷举 switch 对 `RunRecord` 根本不可调用。
38
+ * ⇒ 采「run 句柄域」(施工任务书明列的两案之一):`Task.id` = run 的 `taskId`,`contextId` = `sessionId`,
39
+ * 反枚举门 = 与 `GET /v1/runs/:id` **同一条** owner 判据,态投影 = {@link a2aStateForRun}
40
+ * (与 core `toA2ATaskState` 同纪律:词表增删两向都是编译期红——载体是
41
+ * {@link A2A_STATE_BY_RUN_STATUS} 的 `satisfies`;park 与否另问属主谓词 `isParkedRunStatus`,
42
+ * 本文件零 park 词表。运行期只发九词闭集)。
43
+ * 这不是「绕过 core 原语」:那几只原语的定义域里没有我们要投影的这条行,硬套才是撒谎。
44
+ *
45
+ * ## 🔎 方言对账(codex 轮4 R4-1/R4-2 的驳,证据在码上)
46
+ * 有复审主张我们该改用 `SendMessage`/`GetTask` + `supportedInterfaces` 的 proto 派生绑定。**驳**:本
47
+ * 半场的第一顺位互操作对象是 core 自己那条 A2A **客户端**腿(设计稿 §0 的立项理由就是「client 腿全链
48
+ * 真实现、server 全树零接线」)。装树 `@sema-agent/core/dist/core/a2a.js` 亲读:它发 `message/send`
49
+ * (:513)/`tasks/get`(:483)、置头 `a2a-version: 1.0`(:149,`A2A_PROTOCOL_VERSION = "1.0"` :28)、
50
+ * 先拉 `/.well-known/agent-card.json`(:27)、按**顶层 `url` + `preferredTransport`** 协商(:244)。
51
+ * 照那条建议改,恰好会与我们唯一能亲验的消费方互不相通。这条对账已钉成机器判据(见
52
+ * `a2a-serve-rpc.test.ts` 的「轮4」格):装树换方言时当场红,那才是重新裁定的时刻。
53
+ * 🟠 其中**一条**留作 v2 候选(不在 v1 射程,设计稿 §3 尾已把它列进「明确不做」):卡上不声明任何
54
+ * security scheme ⇒ 发现面不告诉调用方「调用需要凭据」。R-11 裁的是「不假装 OAuth」,而一个如实的
55
+ * `http: bearer` 声明并不构成假装 —— 值得在 v2 连同 card 字段面一起重议。
56
+ *
57
+ * ## 🟡 已知残余(登记,不假装没有)
58
+ * ① `/v1/a2a` 是 `isBillableSubmitPath` 的**静态**成员(纯 url 谓词)。于是在「没有任何 service token
59
+ * 且未开 `ALLOW_UNAUTHED_WRITES`」这一形的部署上,`POST /v1/a2a` 拿到的是 503
60
+ * `auth.service_token_required` 而不是本模块的 404 —— 即便旋钮是 OFF。那是一条很弱的指纹(「这个
61
+ * build 认得 /v1/a2a」),射程仅限一台**拒收一切写**的机器,且 server 的版本/能力面本就可发现。
62
+ * 不修的理由:那道门必须在**解析请求体之前**判(未鉴权的写不该先被解析),而它只有 url 可看。
63
+ * (drain / model-roster 两道**可用性**门已按方法下沉,见 `submitUnavailable` —— 它们不在这条残余里。)
64
+ * ② `prepareSpec` 的**显式字段校验**分支(objective 空、sessionId 超长…)仍以裸 HTTP 4xx 应答,不进
65
+ * JSON-RPC 信封。射程有界且不泄漏:那些文案只谈**本腿自己铸**的字段、不含任何内部词表(会泄漏的
66
+ * typed 分支已经走 `onTypedFailure` 钩子改口,见 `rpcErrorFromTypedFailure`)。彻底收口要把那一串
67
+ * `sendError` 全改成 typed 返回值 —— 那是共享腿的形变更,留给后续批。
68
+ *
69
+ * 分层:本模块只值 import `http/` 的叶子(send / principal-gate / idempotency / route-ctx)、
70
+ * `routes/runs.js` 的**提交腿导出**(单向边,runs.ts 不反向引用本模块 ⇒ 不成环)与 src 下的普通模块
71
+ * —— 绝不值 import server.ts(见 route-ctx.ts 头注的分层硬约束)。
72
+ */
73
+ import { createHash } from "node:crypto";
74
+ import { A2A_TASK_STATES } from "@sema-agent/core";
75
+ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
76
+ import { HttpError } from "../../security.js";
77
+ import { scopedIdempotencyKey } from "../idempotency.js";
78
+ import { sendJson, sendError } from "../send.js";
79
+ import { gatedPrincipal } from "../principal-gate.js";
80
+ import { createDurableRun } from "./runs.js";
81
+ /** 本实现说的 A2A 协议版本 —— 卡的 `protocolVersion` 与每条 RPC 响应的 `a2a-version` 头同源(单点)。 */
82
+ export const A2A_PROTOCOL_VERSION = "1.0";
83
+ /** 版本头名。**读**请求侧的它只是诊断材料(开集容忍:认识与否都照常答),**写**响应侧的它是我们自报
84
+ * 家门 —— 设计稿 §4-1 版本槽。 */
85
+ export const A2A_VERSION_HEADER = "a2a-version";
86
+ /** 配置 → 卡(纯投影,零 I/O、零存量读)。`build*`:返回纯数据。 */
87
+ export function buildAgentCard(serve) {
88
+ return {
89
+ name: serve.name,
90
+ ...(serve.description !== undefined ? { description: serve.description } : {}),
91
+ protocolVersion: A2A_PROTOCOL_VERSION,
92
+ url: serve.url,
93
+ preferredTransport: "JSONRPC",
94
+ // 🔒 逐键**白名单**构造(fleet 的 stripT 同纪律):配置条目新增字段时默认**不**上卡,要上必须在这里
95
+ // 显式写一行 —— 反过来(spread 配置对象再删几个键)会让下一个加到 A2aServeSkill 上的内部字段
96
+ // 静默出现在公网文档上,那正是本行要防的事故形。
97
+ skills: serve.skills.map((s) => ({ id: s.id, name: s.name ?? s.id, ...(s.description !== undefined ? { description: s.description } : {}) })),
98
+ };
99
+ }
100
+ /**
101
+ * 域入口①:公开发现面。**位置即契约** —— 必须挂在 `server.ts` 的全局 service-credential 门**之前**
102
+ * (R-11「公网匿名可发现」;挂在门后等于要凭据才能发现,协商第一步就断)。
103
+ *
104
+ * 命名:本模块有**两个**域入口(卡在凭据门外、RPC 在门内),而 `route-shape-roster` 层E 的判据是
105
+ * 「域名词干 ⊆ 某个 capability 位名」。两个名字都取成 `capabilities.a2aServe` 的**子串**
106
+ * (`a2aServe` / `a2a`),于是两条都被那道门如实认出来 —— 而不是去放宽那道门的判据。
107
+ */
108
+ export async function handleA2aServe(req, res, url, ctx) {
109
+ const miss = { fell: false };
110
+ await handleA2aServeBody(req, res, url, ctx, miss);
111
+ return !miss.fell;
112
+ }
113
+ async function handleA2aServeBody(req, res, url, ctx, miss) {
114
+ const { deps } = ctx;
115
+ if (req.method === "GET" && url === "/.well-known/agent-card.json") {
116
+ const serve = deps.config.a2aServe;
117
+ // OFF ⇒ 404,与「本部署没有这条路由」逐字节同形:对外不存在就是不存在,不发一个「功能已禁用」的
118
+ // 具名错误(那本身就是一条「这里跑的是 sema-server 且它认得 A2A」的指纹)。
119
+ if (!serve) {
120
+ sendError(res, 404, "not_found.route", "not found");
121
+ return;
122
+ }
123
+ res.setHeader(A2A_VERSION_HEADER, A2A_PROTOCOL_VERSION);
124
+ sendJson(res, 200, buildAgentCard(serve));
125
+ return;
126
+ }
127
+ miss.fell = true;
128
+ }
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // 件2:JSON-RPC 端点(服务凭据门**内**)
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+ /** JSON-RPC 2.0 传输层错误码(协议保留段)。 */
133
+ const JSONRPC_PARSE_ERROR = -32700;
134
+ const JSONRPC_INVALID_REQUEST = -32600;
135
+ const JSONRPC_METHOD_NOT_FOUND = -32601;
136
+ const JSONRPC_INVALID_PARAMS = -32602;
137
+ const JSONRPC_INTERNAL_ERROR = -32603;
138
+ /** A2A 自有错误码(spec 的 -32001..-32006 段)。只用我们真会发的三只。 */
139
+ const A2A_TASK_NOT_FOUND = -32001;
140
+ const A2A_UNSUPPORTED_OPERATION = -32004;
141
+ const A2A_CONTENT_TYPE_NOT_SUPPORTED = -32005;
142
+ /**
143
+ * 🔴 **反枚举不变式(A-041)在本模块的执行点**:`tasks/get` 的「这条 task 不存在」与「这条 task 不是
144
+ * 你的」必须**逐字节**同形 —— 同一个 code、同一条 message、同一个(缺席的)data。两臂共用**这一个**
145
+ * 常量就是那条不变式的机制支撑:任何一臂想说得更具体,都得先改这里,而改这里会被
146
+ * `a2a-serve-rpc.test.ts` 的逐字节比对当场钉住。
147
+ *
148
+ * (同族先例:`route-ctx.ts` 的 `RUN_NOT_FOUND_MESSAGE` —— #262 抓到的正是两臂**文案长度**不同就足以
149
+ * 枚举他人 run id 的存在性。JSON-RPC 侧一模一样,只是载体从 HTTP body 换成了 error 对象。)
150
+ */
151
+ export const A2A_TASK_NOT_FOUND_MESSAGE = "task not found";
152
+ /** 无 durable run store 时两个方法的**同一条**具名回答(不是静默降级成一次同步跑)。 */
153
+ const A2A_NO_RUN_STORE_MESSAGE = "this deployment has no durable run store, so A2A tasks cannot be created or polled (set DB_BACKEND=mysql|pg)";
154
+ /**
155
+ * `RunRecord["status"]` → A2A 任务态的**逐词处置表**。
156
+ *
157
+ * `satisfies Record<RunRecord["status"], …>` 是这张表的执法点(fleet 的 `SCOPED_TASK_KEY_DISPOSITION`
158
+ * 同纪律):core 给 `TaskStatus` **增**一个成员 ⇒ 表不完整、tsc 红,加成员的人被迫当场回答「这个新态
159
+ * 投成哪个协议词」;**删**一个成员 ⇒ 多余键同样红。写成表而不是 `switch` 的第二个理由见下面 `null` 那两行。
160
+ *
161
+ * 逐词理由:
162
+ * · `running` → `working`。
163
+ * · `completed` → `completed`。
164
+ * · `failed` → `failed`(`errorCode === "cancelled"` 的分叉在读点:那是**有人明确要求**的停止,写
165
+ * `POST /v1/runs/:id/cancel` 时落的字面量。与 core 对 `killed` 行按 `stoppedBy` 分叉的判词同源 ——
166
+ * `canceled` 断言的是一个意图,没人表达过就不许替他断言)。
167
+ * · `blocked` → `failed`。它是终局的「跑不下去了」;协议的 `rejected` 说的是**准入时**拒收
168
+ * (我们准入拒收会在 RPC 层直接报错、根本不建行),投它会撒谎。
169
+ * · **park 族两词 → `null`**,读作「这个词的归属不归本表裁,归 `store-contracts.ts` 的
170
+ * `isParkedRunStatus`(词表属主:穷举 switch + `never` 臂)」。本文件**不留第二张 park 词表** ——
171
+ * 那正是 `source-hygiene-gate` 点名的病:手抄形绕开属主的编译期执法,core 新增一个 park 类成员时
172
+ * 手抄处会静默判「非 park」。运行期这两个值到不了(读点先问属主);到得了就说明两边不一致,那时
173
+ * 诚实答 `unknown` 好过在这里再猜一次。
174
+ */
175
+ const A2A_STATE_BY_RUN_STATUS = {
176
+ running: "working",
177
+ completed: "completed",
178
+ failed: "failed",
179
+ blocked: "failed",
180
+ suspended: null,
181
+ needs_review: null,
182
+ };
183
+ /**
184
+ * run 行 → A2A 任务态。词表槽(设计稿 §4-2):我们是铸点,只发 core `A2A_TASK_STATES` 九词闭集。
185
+ *
186
+ * 判据分工:park 与否问**属主谓词**;其余按 {@link A2A_STATE_BY_RUN_STATUS} 投影(那张表的
187
+ * `satisfies` 是词表增删两向的编译期执行点)。
188
+ *
189
+ * park → `input-required` 的语义:协议的「等调用方给点什么」在本引擎被**收窄**成「等一个审批决议」,
190
+ * 而那个决议走的是持久审批口、不是一条回信 —— 与 core 对自己 `parked` 行的裁词同源。A2A 调用方因此
191
+ * **不能**靠再发一条 message 解开它(附录 C.3 的表里成文写着这句)。
192
+ */
193
+ export function a2aStateForRun(run, stale) {
194
+ // stale 折叠与 `GET /v1/runs/:id` 同源:那条读面把「running 但心跳超期」直接答成 failed;两个读面
195
+ // 对同一行给出不同的终局判断会是最难查的一类不一致。
196
+ if (stale)
197
+ return "failed";
198
+ if (isParkedRunStatus(run.status))
199
+ return "input-required"; // 判据属主单源,本文件零 park 词表
200
+ // 表外的词 = 滚动升级里更新副本写入的新状态(运行期真会发生:SQL 文本列被裸 cast 成联合)。
201
+ // `unknown` 是九词闭集里**为此而设**的那一个,也是唯一诚实的答案:本进程读不懂这行就说读不懂,
202
+ // 而不是猜一个终局态让对端停止轮询。
203
+ const mapped = A2A_STATE_BY_RUN_STATUS[run.status];
204
+ if (mapped === null || mapped === undefined)
205
+ return "unknown";
206
+ return mapped === "failed" && run.errorCode === "cancelled" ? "canceled" : mapped;
207
+ }
208
+ /** 一条我们铸的 agent 消息(`status.message`)。 */
209
+ function agentMessage(taskId, contextId, text) {
210
+ return {
211
+ kind: "message",
212
+ role: "agent",
213
+ // messageId 由**我们**铸(协议要求每条消息有 id);由 taskId 派生 ⇒ 同一条 task 的重复读稳定同值。
214
+ messageId: `${taskId}:status`,
215
+ parts: [{ kind: "text", text }],
216
+ taskId,
217
+ contextId,
218
+ };
219
+ }
220
+ /** 状态文本。终局 completed 发最终文本(与 `GET /v1/runs/:id` 发的 `result` 是**同一份数据**,同样
221
+ * 不裁剪 —— 裁剪会造出一个「其余部分在哪里」的问题,而这条协议面没有第二个读口可指);失败发错误
222
+ * 文本;park 态发一句**不带任何工具/门细节**的通用说明(对端是外部 agent,不该从状态行里读出我们
223
+ * 内部拦了什么工具)。 */
224
+ function statusTextForRun(run, state) {
225
+ if (state === "input-required")
226
+ return "this task is suspended awaiting an approval decision on the serving deployment; it resumes once that decision is made";
227
+ if (run.result?.result)
228
+ return run.result.result;
229
+ if (run.error)
230
+ return run.error;
231
+ if (run.result?.errorMessage)
232
+ return run.result.errorMessage;
233
+ return undefined;
234
+ }
235
+ /** run 行 → A2A Task。**最小披露**:只发 id / contextId / 态 / 一段状态文本 —— stats、cost、model、
236
+ * source、jobId、owner 一律不上(对端要的是「做完没有、结果是什么」,其余都是内部账)。 */
237
+ function taskFromRun(run, stale) {
238
+ const state = a2aStateForRun(run, stale);
239
+ const text = statusTextForRun(run, state);
240
+ return {
241
+ kind: "task",
242
+ id: run.taskId,
243
+ contextId: run.sessionId,
244
+ status: {
245
+ state,
246
+ timestamp: new Date(run.updatedAt).toISOString(),
247
+ ...(text !== undefined ? { message: agentMessage(run.taskId, run.sessionId, text) } : {}),
248
+ },
249
+ };
250
+ }
251
+ /** 具名 RPC 错误 —— 处理器抛它,分派器翻成 JSON-RPC error 体。 */
252
+ class RpcError extends Error {
253
+ code;
254
+ constructor(code, message) {
255
+ super(message);
256
+ this.code = code;
257
+ }
258
+ }
259
+ // ── 请求形校验(小纯函数,便于逐条钉) ─────────────────────────────────────────
260
+ function asRecord(v, what) {
261
+ if (typeof v !== "object" || v === null || Array.isArray(v))
262
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `${what} must be an object`);
263
+ return v;
264
+ }
265
+ function asNonEmptyString(v, what, max) {
266
+ if (typeof v !== "string" || v.length === 0)
267
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `${what} must be a non-empty string`);
268
+ if (v.length > max)
269
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `${what} must be at most ${max} characters`);
270
+ return v;
271
+ }
272
+ /** 上限:与 `prepareSpec` 的 `sessionId` 列宽同源(64)—— 越界在这里具名拒,不留给 INSERT 去截断。 */
273
+ const MAX_CONTEXT_ID_CHARS = 64;
274
+ /** `messageId` 只当幂等键用,长度按既有 Idempotency-Key 面同量级收窄。 */
275
+ const MAX_MESSAGE_ID_CHARS = 200;
276
+ /** task id 读面上限(uuidv7 远短于此;这一格防的是无界字符串进 store 查询)。 */
277
+ const MAX_TASK_ID_CHARS = 128;
278
+ /** skill id 上限与 config 侧声明面同源。 */
279
+ const MAX_SKILL_ID_CHARS = 128;
280
+ /** `message.parts` → objective。**只收 text part,其余具名拒绝**(v1 定界):静默丢一个 file part
281
+ * 等于把「我没看你给的文件」渲染成「我看过了」。A2A 的 `kind` 与旧稿的 `type` 两种拼法都认(读侧
282
+ * 开集容忍;写侧我们只铸 `kind`)。 */
283
+ function objectiveFromParts(parts) {
284
+ if (!Array.isArray(parts) || parts.length === 0)
285
+ throw new RpcError(JSONRPC_INVALID_PARAMS, "params.message.parts must be a non-empty array");
286
+ const texts = [];
287
+ for (const [i, raw] of parts.entries()) {
288
+ const part = asRecord(raw, `params.message.parts[${i}]`);
289
+ const kind = part.kind ?? part.type;
290
+ if (kind !== "text") {
291
+ throw new RpcError(A2A_CONTENT_TYPE_NOT_SUPPORTED, `params.message.parts[${i}] is of kind ${JSON.stringify(String(kind ?? "(absent)"))} — this agent accepts text parts only in v1 (a file/data part is refused, never silently dropped)`);
292
+ }
293
+ if (typeof part.text !== "string")
294
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `params.message.parts[${i}].text must be a string`);
295
+ if (part.text.length > 0)
296
+ texts.push(part.text);
297
+ }
298
+ const objective = texts.join("\n\n").trim();
299
+ if (objective.length === 0)
300
+ throw new RpcError(JSONRPC_INVALID_PARAMS, "params.message carries no non-empty text part — there is nothing to work on");
301
+ return objective;
302
+ }
303
+ /** 我们在 `message.metadata` 上认的**唯一**扩展键:调用方用它点名一条**卡上公告过**的 skill。
304
+ * A2A spec 本身没有 skill 选择字段(卡只做广告),`metadata` 是它的开放扩展点 —— 命名空间前缀防撞。
305
+ * 缺席 ⇒ 走部署缺省场景(既有行为);在场但不认识 ⇒ **具名拒绝**:路由输入静默回落到缺省场景 =
306
+ * 请求以为自己选了 A、实际跑了 B,是最坏的一种「成功」。 */
307
+ const SKILL_METADATA_KEY = "sema.skillId";
308
+ /** `message.metadata["sema.skillId"]` → 那条**已声明**的 skill(缺席 ⇒ undefined = 走部署缺省场景)。
309
+ * ⚠️ 错误文案里**不回显** scenario —— 卡公开的是 skill id,内部场景键不是公开面。 */
310
+ function skillFromMetadata(metadata, serve) {
311
+ if (metadata === undefined || metadata === null)
312
+ return undefined;
313
+ const meta = asRecord(metadata, "params.message.metadata");
314
+ const raw = meta[SKILL_METADATA_KEY];
315
+ if (raw === undefined)
316
+ return undefined;
317
+ const id = asNonEmptyString(raw, `params.message.metadata["${SKILL_METADATA_KEY}"]`, MAX_SKILL_ID_CHARS);
318
+ const skill = serve.skills.find((s) => s.id === id);
319
+ if (!skill)
320
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `unknown skill id ${JSON.stringify(id)} — the skills this agent serves are the ones listed on its agent card`);
321
+ return skill;
322
+ }
323
+ /**
324
+ * typed 提交拒绝(`HttpError`)→ JSON-RPC 错误。**只带我们自己的话**:原 `message` 与 `extra` 一律不上线。
325
+ *
326
+ * 🔴 codex 轮2 R2-4:那些 `extra` 里装的是给壳看的指路材料 —— `scenario_unknown` 的 extra 是**本部署的
327
+ * 全部场景名**,`scenario_not_allowed` 的是这位 principal 的场景白名单。发给一个**外部 peer** 就是内部
328
+ * 词表泄漏,而 skill 声明制存在的全部理由正是不把场景名给公网(设计稿 §3.1)。这里改口成:只说**公开的**
329
+ * skill id 出了什么问题,真因(哪个场景不存在)只进服务端日志。
330
+ *
331
+ * 4xx = 调用方的参数问题 ⇒ -32602;其余(5xx/意外)⇒ -32603,同样不带内部细节。
332
+ */
333
+ function rpcErrorFromTypedFailure(err, skillId) {
334
+ const where = skillId !== undefined ? `skill ${JSON.stringify(skillId)}` : "this request";
335
+ if (err.status >= 400 && err.status < 500) {
336
+ return new RpcError(JSONRPC_INVALID_PARAMS, `the serving deployment refused this submission for ${where} (its configuration does not currently admit it); the operator's logs carry the reason`);
337
+ }
338
+ return new RpcError(JSONRPC_INTERNAL_ERROR, "internal error");
339
+ }
340
+ /**
341
+ * **可用性**两门,按**方法**判(不是按 url)。
342
+ *
343
+ * `handle()` 里那两道 503(drain / model-roster-pending)挂在 `isBillableSubmitPath` 上,而那是一张**纯
344
+ * url** 谓词表 —— 对 `/v1/a2a` 一刀切的代价是 `tasks/get` 这条纯读也一起 503。单副本部署(或卡上公告的
345
+ * 就是这一台)于是恰好在重启/依赖降级的窗口里取不回已完成的结果,而那正是外部 peer 最需要读的时刻。
346
+ * ⇒ `handle()` 对本 url **跳过**这两道(见 `isMethodDispatchedSubmitPath`),改由本函数在 `message/send`
347
+ * 里施加。**服务凭据门那一道不动**:它判的是「这台机没有任何 service token 时能不能收写」,与方法无关,
348
+ * 而把它挪进方法层会让一次未鉴权的提交先被解析、再被拒 —— 门必须尽早。
349
+ * 文案与 `handle()` 的两道逐字同源(消费端按 `error:"draining"` 字面判型,那是冻结的 wire 契约)。
350
+ */
351
+ function submitUnavailable(res, ctx) {
352
+ const { deps } = ctx;
353
+ if (deps.drainState?.draining) {
354
+ res.setHeader("retry-after", "15");
355
+ sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance" });
356
+ return true;
357
+ }
358
+ if (deps.modelReady && !deps.modelReady()) {
359
+ res.setHeader("retry-after", "5");
360
+ sendError(res, 503, "state.model_roster_pending", "model_roster_pending", { message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" });
361
+ return true;
362
+ }
363
+ return false;
364
+ }
365
+ /** `configuration.blocking` 的读取。spec 缺省 false;非布尔值**具名拒**(一个 `"true"` 字符串被当成
366
+ * 真,会让调用方以为自己在同步等)。 */
367
+ function readBlocking(params) {
368
+ if (params.configuration === undefined)
369
+ return false;
370
+ const c = asRecord(params.configuration, "params.configuration");
371
+ if (c.blocking === undefined)
372
+ return false;
373
+ if (typeof c.blocking !== "boolean")
374
+ throw new RpcError(JSONRPC_INVALID_PARAMS, "params.configuration.blocking must be a boolean");
375
+ return c.blocking;
376
+ }
377
+ /** 与 `GET /v1/runs/:id` 逐字同源的 stale 判据(running 但心跳超期 ⇒ 折成 failed)。 */
378
+ function isStaleRun(run, runStaleSec) {
379
+ return run.status === "running" && Date.now() - new Date(run.updatedAt).getTime() > runStaleSec * 1000;
380
+ }
381
+ /**
382
+ * 有界等待一条 run 走出 `working`。`budgetMs<=0` ⇒ 只读一次(非 blocking 形)。
383
+ * 轮询 durable 行而不是挂进程内事件:这条 run 可能由**另一个副本**驱动,durable 行才是跨副本的唯一真相。
384
+ *
385
+ * 🔴 codex 轮4 R4-3 随修的两条**止损**(同族病:一个不看外界的循环在依赖降级时最能放大伤害):
386
+ * ① **对端断了就停**:每拍检查 `res.writableEnded / socket.destroyed`。没有这一条,一个已经走掉的
387
+ * 调用方仍能让我们对库读满整个窗(fleet 那条流的 ackIfAlive 是同一句判词)。
388
+ * ② **库读失败不再静默当 miss**:原先 `.catch(() => undefined)` 会把一次真故障读成「还没好」,于是
389
+ * 在库正挂着的时候反而把读打满(把故障隐藏 + 放大)。改成**当拍停**并把已知的最后一手交回去 ——
390
+ * 调用方拿到的是 `submitted`/上一拍的真状态,自己去轮询,而不是我们替他空转。
391
+ */
392
+ async function waitForRun(ctx, res, taskId, budgetMs) {
393
+ const { deps } = ctx;
394
+ const store = deps.runStore;
395
+ if (!store)
396
+ return null;
397
+ const deadline = Date.now() + budgetMs;
398
+ let last = null;
399
+ for (;;) {
400
+ let run;
401
+ try {
402
+ run = (await store.getRun(taskId)) ?? null;
403
+ }
404
+ catch (e) {
405
+ deps.logger?.warn?.("a2a_serve_wait_read_failed", { error: e?.message });
406
+ return last; // ② 读挂了就别再敲它
407
+ }
408
+ last = run ?? last;
409
+ if (run && a2aStateForRun(run, isStaleRun(run, deps.config.runStaleSec)) !== "working")
410
+ return run;
411
+ const left = deadline - Date.now();
412
+ if (left <= 0)
413
+ return run;
414
+ if (res.writableEnded || res.socket?.destroyed)
415
+ return run; // ① 对端已走,别替他继续读库
416
+ await new Promise((r) => {
417
+ const t = setTimeout(r, Math.min(250, Math.max(1, left)));
418
+ if (typeof t.unref === "function")
419
+ t.unref();
420
+ });
421
+ }
422
+ }
423
+ /**
424
+ * `message/send` —— 翻译进**既有**任务提交链。
425
+ *
426
+ * 复用点(不另铸提交路径):`ctx.legs.prepareSpec(req, res, body)` 的读体/校验/建 spec 段 +
427
+ * {@link createDurableRun}(`POST /v1/runs` 的建行+驱动段,本车从 runs.ts 提取成具名腿,两个调用方
428
+ * 共用同一份实现)。本模块**没有**自己的 `createRun` / `runInBackground` 调用。
429
+ *
430
+ * blocking 语义:A2A `configuration.blocking` 缺省 false(spec)。true 时按 `A2A_SERVE_BLOCKING_WAIT_MS`
431
+ * 有界等待 —— blocking 的意思是「尽力等」,不是「必须等到终局」:窗到期回 `working` 态 + id,调用方按
432
+ * 协议转 `tasks/get` 轮询,这是**正常路径**不是降级。窗必须远小于 core 客户端腿的 10min 总预算:那是
433
+ * **调用方**的钱包,而按住一条 inbound 请求 10 分钟花的是**我们**的连接池。
434
+ */
435
+ async function handleMessageSend({ req, res, ctx, params, serve }) {
436
+ const { deps } = ctx;
437
+ const { rateLimited, quotaExceeded } = ctx.helpers;
438
+ const message = asRecord(params.message, "params.message");
439
+ if (message.role !== "user")
440
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `params.message.role must be "user" (this is the inbound leg — an agent-role message has no meaning here)`);
441
+ const objective = objectiveFromParts(message.parts);
442
+ // contextId = 我们的 sessionId(协议的「同一段对话」与本引擎的 session 是同一个概念)。缺席 ⇒ 提交链铸新的。
443
+ const contextId = message.contextId === undefined ? undefined : asNonEmptyString(message.contextId, "params.message.contextId", MAX_CONTEXT_ID_CHARS);
444
+ // 🔴 codex 轮 H2a:`messageId` **必填**。协议本就把它列为 `Message` 的必填字段,而在这里它还兼着
445
+ // **幂等键** —— 收一条没有它的消息 = 悄悄把去重整个关掉(重发即第二条 run、第二笔账),那正是
446
+ // 「无声 fail-open」。缺席具名拒,不给「宽容」留口子。
447
+ const messageId = asNonEmptyString(message.messageId, "params.message.messageId", MAX_MESSAGE_ID_CHARS);
448
+ const skill = skillFromMetadata(message.metadata, serve);
449
+ const skillId = skill?.id;
450
+ const scenario = skill?.scenario;
451
+ // 🔴 codex 轮3 R3-1(验真后修):**请求形校验必须全部走完再动状态**。这一行原先在下面等待窗那里才求值 ——
452
+ // 于是一个 `blocking:"true"`(字符串)会拿到 -32602,而一条计费 run 已经建好并在跑:调用方既没有收据、
453
+ // 重试还会再开一条。「拒了却已经烧上钱」是最坏的组合,校验不该有先后之分。
454
+ const blocking = readBlocking(params);
455
+ if (!deps.runStore)
456
+ throw new RpcError(A2A_UNSUPPORTED_OPERATION, A2A_NO_RUN_STORE_MESSAGE);
457
+ // 幂等两道(与 `POST /v1/runs` **同一对**机制,不新铸第三种):
458
+ // ① in-memory:按身份加盐的 `Idempotency-Key` 缓存 —— 同副本内的并发/快速重试共享同一次产出。
459
+ // ② durable:caller-minted `body.taskId` 重放,读的是 run **store** ⇒ 跨副本、跨重启都成立。
460
+ // A2A 调用方不知道我们的 taskId 体例,所以由**我们**从 (身份, messageId) 派生一个 uuidv7 形的 id。
461
+ // 🔒 **按身份加盐**是承重的,不是装饰:不加盐时两位租户用同一个 messageId 会派生出同一个 taskId,
462
+ // 后到的那位撞上 `409 taskId already exists` —— 那是一个现成的跨租户存在性 oracle(猜 messageId
463
+ // 就能验证别人发过没有),与 A-041 直接冲突。加盐后各租户各得各的键,那条臂对 A2A 面不可达。
464
+ //
465
+ // 🔴 codex 轮2 R2-2(验真后修):键里**必须**含请求内容。只按 (身份, messageId) 取键时,一个复用了
466
+ // 旧 messageId 的**新**请求会被判成重试 —— 它**从未执行**,而调用方拿回一条与自己所问无关的旧结果
467
+ // (无凭据部署下两位调用方还共用同一个键空间,后果更糟)。判据改成:幂等键 = (身份, messageId,
468
+ // **这条消息的规范化内容**)。于是 ①真重试(逐字节相同)照旧只跑一次;②同 id 换内容 = 另一条任务,
469
+ // 真的会被执行。v1 **不**做 Stripe 式「同键异体 ⇒ 409 拒」——那需要一列持久指纹(= SQL 面,不在本车),
470
+ // 而它要防的伤害在此已经没有了:代价从「静默回放无关结果」降到「多一条本来就该跑的任务」。
471
+ const idemScope = `${ctx.req.source ?? ""}\x1f${gatedPrincipal(req, deps.config) ?? ""}`;
472
+ //
473
+ // 🔴 codex 轮3 R3-3(验真后修):指纹只由**线上输入**构成 —— 取调用方给的 `skillId`,**不取**服务端
474
+ // 解析出的内部 `scenario`。后者是**可变的服务端路由**:滚动配置变更期两台副本可以把同一个已公告的
475
+ // skill 映射到不同场景 ⇒ 同一条线请求在两台上派生出不同 taskId ⇒ 一次 failover 重试开出第二条 run,
476
+ // 恰恰推翻我们对外承诺的「跨副本/跨重启只有一条」。幂等的自变量必须是调用方看得见的那些字节。
477
+ const fingerprint = createHash("sha256")
478
+ .update(messageId).update("\x1f")
479
+ .update(objective).update("\x1f")
480
+ .update(contextId ?? "").update("\x1f")
481
+ .update(skillId ?? "")
482
+ .digest("hex");
483
+ const idemKey = scopedIdempotencyKey(fingerprint, ctx.req.source, gatedPrincipal(req, deps.config));
484
+ const body = {
485
+ objective,
486
+ taskId: durableIdempotencyTaskId(idemScope, fingerprint),
487
+ ...(contextId !== undefined ? { sessionId: contextId } : {}),
488
+ ...(scenario !== undefined ? { scenario } : {}),
489
+ };
490
+ // 🔴 codex 轮 H1:准入门。顺序与 `POST /v1/runs` 逐字同源 —— **重放先于 rate/quota**(一次重试不该
491
+ // 因为窗满改判 429、把一条已经建好的 run 的收据吞掉;那条口的「复审 C1」注写的就是这件事),
492
+ // 而一次**真新**的提交必须与原生提交面吃同一组限流与累计成本门。修前这条链上只有 lease/治理窗,
493
+ // 于是一个已鉴权的外部 peer 可以无视本部署配置的 `RATE_LIMIT_RPM` / `MAX_PRINCIPAL_COST_USD`。
494
+ // 🔴 codex 轮3 R3-2(验真后修):**durable 重放是 message/send 的第一个有状态动作**,排在 rate/quota
495
+ // 之前。只查进程内缓存不够 —— 丢响应后的重试、重启后的重试、LB failover 全都落到一台**缓存是空的**
496
+ // 副本上;那时先撞 429 的后果不是「稍后再试」,而是调用方**永远拿不到**一条已经在跑、已经在计费的
497
+ // task 的句柄。与「重放先于 rate/quota」是同一条判据,只是把它从进程内延伸到跨副本。
498
+ // owner 门与 `tasks/get` 同源(验证过的 principal;`owner === null` = 无主部署的行)。
499
+ const replay = await deps.runStore.getRun(body.taskId).catch(() => undefined);
500
+ if (replay && (replay.owner === null || replay.owner === gatedPrincipal(req, deps.config))) {
501
+ // 🔴 codex 轮4 R4-3(验真后修):重放**立即**作答,**不**进等待循环 —— 哪怕 `blocking:true`。
502
+ // 理由是位置决定的:这一段排在 rate/quota **之前**(R3-2 要求如此,否则重试拿不到已计费 task 的
503
+ // 句柄),于是任何在这里发生的等待都**不受任何准入门约束** —— 一场重试风暴能对同一条 task 叠出
504
+ // 任意多个各自每 250ms 读一次库的独立循环,而且恰好发生在依赖降级、库最脆弱的时候。
505
+ // 语义上也正确:重放的对象是一条**已经受理**的 task,协议给调用方的出路就是 `tasks/get` 轮询;
506
+ // `blocking` 是「这次**提交**尽力等」,不是「每次重试都再等一轮」。
507
+ return taskFromRun(replay, isStaleRun(replay, deps.config.runStaleSec));
508
+ }
509
+ const cached = idemKey ? ctx.registry.idemCache.peek(idemKey) : undefined;
510
+ let receipt;
511
+ if (cached) {
512
+ receipt = await cached;
513
+ }
514
+ else {
515
+ // 🔴 codex 轮2 R2-3(验真后修):**可用性**两门(drain / model-roster-pending)在这里、按**方法**判,
516
+ // 而不是在 `handle()` 里按 url 判 —— 见 `isMethodDispatchedSubmitPath` 的顶注:整条 `/v1/a2a` 按 url
517
+ // 一刀切会让 `tasks/get` 这条**纯读**在排空/roster 未落时一并 503,而单副本部署恰恰在重启窗里最需要
518
+ // 取回已完成的结果。写面照旧 503(一台正在下线的副本不许开新模型工作)。
519
+ if (submitUnavailable(res, ctx))
520
+ return undefined;
521
+ if (rateLimited(req, res) || quotaExceeded(req, res))
522
+ return undefined; // 已应答 429
523
+ let typed;
524
+ const prepared = await ctx.legs.prepareSpec(req, res, body, (e) => {
525
+ typed = e;
526
+ });
527
+ if (typed)
528
+ throw rpcErrorFromTypedFailure(typed, skillId);
529
+ if (!prepared)
530
+ return undefined; // prepareSpec 的显式字段校验分支已自行应答(见下方 🟡 残余注)
531
+ receipt = await createDurableRun(req, res, ctx, prepared, idemKey);
532
+ }
533
+ if (!receipt)
534
+ return undefined; // 提交腿已应答
535
+ const receiptBody = receipt.body;
536
+ if (receipt.status !== 202 || typeof receiptBody.taskId !== "string" || typeof receiptBody.sessionId !== "string") {
537
+ // 既有提交链的非 202 收据(会话占用 409 等)。翻成具名 RPC 错误,**不**假装建成了一条 task。
538
+ throw new RpcError(JSONRPC_INVALID_PARAMS, `the serving deployment did not accept this submission (it answered ${receipt.status}); a session already running a turn must finish or be cancelled before the next message on the same contextId`);
539
+ }
540
+ const taskId = receiptBody.taskId;
541
+ const sessionId = receiptBody.sessionId;
542
+ const run = await waitForRun(ctx, res, taskId, blocking ? serve.blockingWaitMs : 0);
543
+ if (run)
544
+ return taskFromRun(run, isStaleRun(run, deps.config.runStaleSec));
545
+ // 行刚建完还没读回来(或读失败):照 202 收据答 `submitted` —— 协议里正是「已受理、尚未开始」那个词。
546
+ return {
547
+ kind: "task",
548
+ id: taskId,
549
+ contextId: sessionId,
550
+ status: { state: "submitted", timestamp: new Date().toISOString() },
551
+ };
552
+ }
553
+ /**
554
+ * (身份, messageId) → 一个 **uuidv7 形**的确定性 taskId,喂给既有的 caller-minted `body.taskId`
555
+ * durable 幂等通道(`createDurableRun` 里那段读 run store 的重放)。
556
+ *
557
+ * 为什么派生而不是直接用 messageId:那条通道的形门是 `isUuidV7`(server 自己铸的字母表,防调用方注入
558
+ * 任意 PK 形),而 A2A 的 `messageId` 是任意字符串。派生同时解决三件事 —— 形合法、**跨副本/跨重启**
559
+ * 生效(读的是 store,不是进程内缓存)、且**按身份加盐**(见调用点的 🔒 注:不加盐就是一个跨租户
560
+ * 存在性 oracle)。
561
+ *
562
+ * 摘要 → uuid 的位改写:第 7 字节高半字节写 `7`(version),第 9 字节高两位写 `10`(RFC4122 variant),
563
+ * 与 `UUIDV7_RE` 的两处约束一一对应。**不含时间戳**(v7 通常前 48 位是毫秒时钟)——本 id 的用途是
564
+ * 「同一条消息恒定映射到同一行」,时间序会破坏这一点;形门只校形,不校时钟。
565
+ */
566
+ function durableIdempotencyTaskId(scope, messageId) {
567
+ const digest = createHash("sha256").update(scope).update("\x1f").update(messageId).digest();
568
+ const b = Buffer.from(digest.subarray(0, 16));
569
+ b[6] = (b[6] & 0x0f) | 0x70;
570
+ b[8] = (b[8] & 0x3f) | 0x80;
571
+ const hex = b.toString("hex");
572
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
573
+ }
574
+ /**
575
+ * `tasks/get` —— 读回一条 task。
576
+ *
577
+ * 🔴 反枚举:**不存在**与**不属于你**走同一条出路({@link A2A_TASK_NOT_FOUND_MESSAGE},同 code 同文,
578
+ * 逐字节相同)。这是 A-041 不变式在 JSON-RPC 侧的执行点 —— 一个外部 peer 不该能靠错误文案的差别枚举
579
+ * 本部署里别人的 task id。principal 门在**查库之前**求值(与 `GET /v1/runs/:id` 同序),免得 401 与
580
+ * not-found 的先后本身成为存在性 oracle;那道门在分派器入口(见本文件的域入口②)。
581
+ */
582
+ async function handleTasksGet({ req, ctx, params }) {
583
+ const { deps } = ctx;
584
+ const id = asNonEmptyString(params.id, "params.id", MAX_TASK_ID_CHARS);
585
+ if (!deps.runStore)
586
+ throw new RpcError(A2A_UNSUPPORTED_OPERATION, A2A_NO_RUN_STORE_MESSAGE);
587
+ const run = await deps.runStore.getRun(id);
588
+ if (!run)
589
+ throw new RpcError(A2A_TASK_NOT_FOUND, A2A_TASK_NOT_FOUND_MESSAGE);
590
+ // owner 判据与 `runOwnerOk` 同源:比对**验证过的** principal(gatedPrincipal),绝不比 principal 头
591
+ // (直连门上那是可伪造的 —— 伪一个头就能读别人的 task)。`owner === null` = 无主部署的行,任何已过
592
+ // 服务凭据门的调用方可读(与那条 HTTP 读面逐字同判)。
593
+ if (run.owner !== null && gatedPrincipal(req, deps.config) !== run.owner)
594
+ throw new RpcError(A2A_TASK_NOT_FOUND, A2A_TASK_NOT_FOUND_MESSAGE);
595
+ return taskFromRun(run, isStaleRun(run, deps.config.runStaleSec));
596
+ }
597
+ /** 演化槽 §4-4 —— **方法表**。新方法 = 加一行;表外的一切恒 -32601(协议内置的能力协商)。 */
598
+ const A2A_METHODS = {
599
+ "message/send": handleMessageSend,
600
+ "tasks/get": handleTasksGet,
601
+ };
602
+ /**
603
+ * 域入口②:JSON-RPC 端点。**位置即契约** —— 挂在全局 service-credential 门**之后**(R-11「持服务凭据
604
+ * 才可调」),且在 drain / model-roster-pending 两道 503 门之后(`/v1/a2a` 进 `isBillableSubmitPath`:
605
+ * `message/send` 会烧模型)。
606
+ *
607
+ * ⚠️ 代价成文:端点是**单 URL** 的(卡上只能公告一个 `url`),所以排空期 `tasks/get` 也一并 503。
608
+ * 方向取「宁紧」—— 让一台正在下线的副本开新模型工作,正是那几道门存在的全部理由;而读面的补偿是
609
+ * 现成的:行是 durable 的,调用方按 A2A 的传输层重试语义换一台副本读同一条行即可。
610
+ *
611
+ * 命名(`handleA2a`,与 `handleA2aServe` 同族):见域入口①的命名注 —— 两个名字都取成
612
+ * `capabilities.a2aServe` 的子串,好让 `route-shape-roster` 层E 如实认出它们。
613
+ */
614
+ export async function handleA2a(req, res, url, ctx) {
615
+ const miss = { fell: false };
616
+ await handleA2aRpcBody(req, res, url, ctx, miss);
617
+ return !miss.fell;
618
+ }
619
+ async function handleA2aRpcBody(req, res, url, ctx, miss) {
620
+ const { deps } = ctx;
621
+ if (!(req.method === "POST" && url === "/v1/a2a")) {
622
+ miss.fell = true;
623
+ return;
624
+ }
625
+ const serve = deps.config.a2aServe;
626
+ if (!serve) {
627
+ // OFF ⇒ 与卡同形的 404(整条路由对外不存在),不是「功能已禁用」的具名错误。
628
+ sendError(res, 404, "not_found.route", "not found");
629
+ return;
630
+ }
631
+ // 版本槽:**读**请求头只为诊断,认识与否都照常答;响应恒带自己的版本(开集容忍)。
632
+ const requestedVersion = req.headers[A2A_VERSION_HEADER];
633
+ res.setHeader(A2A_VERSION_HEADER, A2A_PROTOCOL_VERSION);
634
+ // principal 门在**任何查库之前**(与 runs 面同序:401 与 not-found 的先后不得成为存在性 oracle)。
635
+ if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
636
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "principal_required" });
637
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
638
+ return;
639
+ }
640
+ let payload;
641
+ try {
642
+ payload = await ctx.helpers.readJson(req);
643
+ }
644
+ catch {
645
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "parse_error" });
646
+ sendRpc(res, { jsonrpc: "2.0", id: null, error: { code: JSONRPC_PARSE_ERROR, message: "request body is not valid JSON" } });
647
+ return;
648
+ }
649
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
650
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "invalid_request" });
651
+ sendRpc(res, { jsonrpc: "2.0", id: null, error: { code: JSONRPC_INVALID_REQUEST, message: "request must be a single JSON-RPC 2.0 object (batch requests are not supported)" } });
652
+ return;
653
+ }
654
+ const rpc = payload;
655
+ const id = typeof rpc.id === "string" || typeof rpc.id === "number" ? rpc.id : null;
656
+ if (rpc.jsonrpc !== "2.0" || typeof rpc.method !== "string" || id === null) {
657
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "invalid_request" });
658
+ sendRpc(res, { jsonrpc: "2.0", id, error: { code: JSONRPC_INVALID_REQUEST, message: 'request must carry jsonrpc:"2.0", a string method and a non-null string|number id (notifications are not supported)' } });
659
+ return;
660
+ }
661
+ const method = rpc.method;
662
+ // 方法槽:表里没有 ⇒ 恒 -32601。这是协议**自带**的能力协商 —— 调用方据此降级(例如 message/stream
663
+ // 拿到 -32601 就转 message/send + tasks/get 轮询),所以我们不自铸一张能力表。
664
+ const handler = A2A_METHODS[method];
665
+ if (!handler) {
666
+ deps.metrics?.inc("a2a_serve_rpc_total", { method: "(unknown)", outcome: "method_not_found" });
667
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "method_not_found" });
668
+ sendRpc(res, { jsonrpc: "2.0", id, error: { code: JSONRPC_METHOD_NOT_FOUND, message: `method not found: ${method}` } });
669
+ return;
670
+ }
671
+ const params = rpc.params === undefined ? {} : rpc.params;
672
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
673
+ deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: "invalid_params" });
674
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: "invalid_params" });
675
+ sendRpc(res, { jsonrpc: "2.0", id, error: { code: JSONRPC_INVALID_PARAMS, message: "params must be an object (positional params are not supported)" } });
676
+ return;
677
+ }
678
+ // 版本头进日志(诊断维),**不**进任何判据;限长防超长头把日志行撑爆(车1 自审第四镜同族)。
679
+ deps.logger?.debug?.("a2a_serve_rpc", { method, ...(typeof requestedVersion === "string" ? { requestedVersion: requestedVersion.slice(0, 32) } : {}) });
680
+ try {
681
+ const result = await handler({ req, res, ctx, params: params, serve });
682
+ if (result === undefined) {
683
+ // 处理器已自行应答(提交链的 400/409 走 HTTP 形 —— 那是**传输层**的拒绝,不是一次成功的 RPC)。
684
+ deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: "refused" });
685
+ return;
686
+ }
687
+ deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: "ok" });
688
+ sendRpc(res, { jsonrpc: "2.0", id, result });
689
+ }
690
+ catch (e) {
691
+ if (e instanceof RpcError) {
692
+ deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: `error_${e.code}` });
693
+ deps.metrics?.inc("a2a_serve_rejected_total", { reason: `rpc_${e.code}` });
694
+ sendRpc(res, { jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
695
+ return;
696
+ }
697
+ // 非具名异常:回一条**不含内部细节**的 -32603(栈/消息只进日志,不上线)。
698
+ deps.logger?.error?.("a2a_serve_rpc_failed", { method, error: e?.message });
699
+ deps.metrics?.inc("a2a_serve_rpc_total", { method, outcome: "internal_error" });
700
+ sendRpc(res, { jsonrpc: "2.0", id, error: { code: JSONRPC_INTERNAL_ERROR, message: "internal error" } });
701
+ }
702
+ }
703
+ /** JSON-RPC 响应体。**HTTP 恒 200**(错误在 body 的 `error` 成员里)—— 这是 JSON-RPC over HTTP 的常规:
704
+ * 把 RPC 级错误映射成 4xx 会让传输层的重试逻辑把「参数写错了」当成「网络抖了」反复重发。 */
705
+ function sendRpc(res, body) {
706
+ sendJson(res, 200, body);
707
+ }
708
+ /** 九词闭集的对外可见锚(测试用它证明我们只发闭集词;也让「加一个自造词」在这里就被看见)。 */
709
+ export const A2A_SERVE_TASK_STATES = A2A_TASK_STATES;
710
+ //# sourceMappingURL=a2a-serve.js.map