dsh-a2a-groups 0.0.0-stage → 0.1.2

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,1167 @@
1
+ /**
2
+ * A2A 1.0 客户端(bridge):发现对端、发一条消息、订阅一条流。
3
+ *
4
+ * ## 这个文件解决什么问题
5
+ *
6
+ * 把「一个对端地址 + 一把可选凭证」收敛成三个动作:
7
+ * - `discover(target)` —— 我能不能调它、它自称能干什么(Agent Card,带 TTL 缓存);
8
+ * - `send(target, input)` —— 一问一答(JSON-RPC `SendMessage`),拿回任务结论;
9
+ * - `stream(target, input)` —— 订阅或长任务进度(`SendStreamingMessage`,SSE)。
10
+ * 上层(工具层、群订阅器)只跟这三个动作打交道,不必知道 JSON-RPC 信封、SSE 分帧,
11
+ * 或 AISIX 网关对卡片 url 的改写行为。
12
+ *
13
+ * ## 边界(明确不做的事)
14
+ *
15
+ * - **不含任何 DSH 概念**:不碰 Session、不碰磁盘,只 `import node:*`,绝不 import
16
+ * `@deepseek-ai/*` 或第三方包(CONTRACT §0.1:插件由普通 Node 解析加载,bare
17
+ * specifier 解析不到 DSH 安装目录)。
18
+ * - **不持有任务真相**:任务状态在对端,本模块只把这一次的结论回给调用方。
19
+ * - **只在「这条流本来就可续」时重连**:调用方给了 `sinceSeq` 才算可续;否则重连
20
+ * 等于把同样的事件再投一遍,对群的收件人就是重复消息。
21
+ *
22
+ * ## 为什么总是 POST 到 `baseUrl`,而不是卡片里的 `url`
23
+ *
24
+ * AISIX 网关在自己没收到 `X-Forwarded-Proto` 时会假设前面有 TLS 终结器,于是把卡片
25
+ * 里的 `url` 写成 `https://<host>:<port>/a2a/<agent>`,而本地联调实际是 `http`。
26
+ * 卡片 url 是「对端自我描述」,`baseUrl` 是「调用方实测可达的地址」;两者冲突时用
27
+ * `baseUrl` 并 warn 一次——否则会掉进「卡片取得到、消息发不出去」这个最费时间的坑。
28
+ *
29
+ * @module dsh-a2a/bridge
30
+ */
31
+
32
+ import { createHash, randomUUID } from 'node:crypto';
33
+
34
+ /** 我们唯一实现的 A2A 线格式版本(CONTRACT §1:显式 1.0,不猜)。 */
35
+ export const A2A_VERSION = '1.0';
36
+
37
+ /** 线上 skill 的缺省值:把文本投进对端绑定的会话并等这一回合结束。 */
38
+ export const DEFAULT_SKILL = 'session/ask';
39
+
40
+ const DEFAULT_TIMEOUT_MS = 120000;
41
+ const DEFAULT_CARD_TTL_MS = 300000;
42
+ const DEFAULT_RECONNECT_ATTEMPTS = 3;
43
+
44
+ /**
45
+ * 重连退避的起点与上限。
46
+ *
47
+ * 250ms 起步足够让「网关刚重启」「网络瞬断」这种秒级抖动自愈,又不会让调用方
48
+ * 在演示时盯着一个不动的界面太久;上限 8s 避免多试图叠加成几十秒的空等。
49
+ */
50
+ const RECONNECT_BASE_DELAY_MS = 250;
51
+ const RECONNECT_MAX_DELAY_MS = 8000;
52
+
53
+ /** 错误正文只留这么多:够定位问题,又不会把整页 HTML 错误页灌进日志。 */
54
+ const MAX_ERROR_BODY_CHARS = 400;
55
+
56
+ /** 线上状态枚举名前缀(protobuf JSON 的枚举名)。 */
57
+ const WIRE_STATE_PREFIX = 'TASK_STATE_';
58
+
59
+ /**
60
+ * 可以合法结束一条流的状态。
61
+ *
62
+ * A2A 用 `final:true` 标记流结束,但那个标记要跟状态**对上**:`submitted` / `working`
63
+ * 的帧声称自己是最后一帧,是自相矛盾的(真实见过:把 `final` 算成
64
+ * `state !== 'WORKING'` 的实现,会让第一帧 `submitted` 就带上 `final:true`)。
65
+ * 遇到这种帧不能就这么收线——否则一条正常的流会在第一帧后静默截断。
66
+ *
67
+ * `input_required` / `auth_required` 也在里面:它们表示「这一轮到此为止,等调用方
68
+ * 补充信息」,流确实该结束。
69
+ */
70
+ const TERMINAL_STATES = new Set([
71
+ 'completed',
72
+ 'canceled',
73
+ 'failed',
74
+ 'rejected',
75
+ 'input_required',
76
+ 'auth_required',
77
+ ]);
78
+
79
+ /**
80
+ * JSON-RPC 错误码 → 我们的一级分类(CONTRACT §1 的错误码表)。
81
+ *
82
+ * 只映射「调用方能自救」的那三个:认证没带对可以去补 key,权限不对得找管理员开
83
+ * ACL,版本不对得改配置。其余(-326xx 与 -32004)都是对端或业务层面的拒绝,归 rpc。
84
+ */
85
+ const RPC_ERROR_KINDS = new Map([
86
+ [-32001, 'auth'],
87
+ [-32002, 'permission'],
88
+ [-32003, 'version'],
89
+ ]);
90
+
91
+ /**
92
+ * `onEvent` 自己抛的异常的包装。
93
+ *
94
+ * 存在的唯一理由:把它和「网络断了」区分开。网络错误可以重连,调用方的回调错误
95
+ * 不能——重连会把事件再投一遍。类放在这里(而不是 bridge 内部)是因为 `once()`
96
+ * 里要用 `instanceof` 判断。
97
+ */
98
+ class EventDeliveryError extends Error {
99
+ /** @param {unknown} cause - 调用方回调抛出的原始异常。 */
100
+ constructor(cause) {
101
+ super('the onEvent callback threw', { cause });
102
+ this.name = 'EventDeliveryError';
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 一次 A2A 客户端失败。
108
+ *
109
+ * `kind` 是预先分好类的动作提示,调用方据此决定「补凭证 / 找管理员 / 改配置 / 重试 /
110
+ * 把错误原文回给模型」,而不是去匹配 error message 的字符串。
111
+ */
112
+ export class A2aClientError extends Error {
113
+ /**
114
+ * @param {'auth'|'permission'|'version'|'network'|'rpc'} kind - 一级分类。
115
+ * @param {string} message - 面向人的说明(**绝不包含明文凭证**)。
116
+ * @param {object} [detail] - 结构化细节。
117
+ * @param {number|string} [detail.code] - JSON-RPC 错误码,或稳定的子码(如 `aborted`、`bad_target`)。
118
+ * @param {number} [detail.status] - HTTP 状态码(若有)。
119
+ * @param {string} [detail.data] - 错误正文摘录(已截断)。
120
+ * @param {unknown} [detail.cause] - 原始异常,便于上层打日志。
121
+ */
122
+ constructor(kind, message, detail = {}) {
123
+ super(message, detail.cause === undefined ? undefined : { cause: detail.cause });
124
+ this.name = 'A2aClientError';
125
+ this.kind = kind;
126
+ this.code = detail.code;
127
+ this.status = detail.status;
128
+ this.data = detail.data;
129
+ }
130
+
131
+ /**
132
+ * 结构化输出(日志 / 工具返回值用)。
133
+ * @returns {{kind: string, code: (number|string|undefined), status: (number|undefined), message: string}}
134
+ */
135
+ toJSON() {
136
+ return { kind: this.kind, code: this.code, status: this.status, message: this.message };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 把凭证压成 `sha256:前8位`,只用于日志与错误信息(CONTRACT §0.6)。
142
+ *
143
+ * 用哈希而不是 `***` 的原因是:出问题时能比对「两边是不是同一把 key」,
144
+ * 又不把明文写进任何持久化产物。
145
+ *
146
+ * @param {unknown} value - 明文凭证。
147
+ * @returns {string} 可直接进日志的短标识;空值返回空串。
148
+ */
149
+ export function maskSecret(value) {
150
+ if (typeof value !== 'string' || value === '') return '';
151
+ return `sha256:${createHash('sha256').update(value).digest('hex').slice(0, 8)}`;
152
+ }
153
+
154
+ /* -------------------------------------------------------------------------- */
155
+ /* 小工具 */
156
+ /* -------------------------------------------------------------------------- */
157
+
158
+ /** 取字符串,非字符串一律当空。 */
159
+ function str(value) {
160
+ return typeof value === 'string' ? value : '';
161
+ }
162
+
163
+ /** 取有限数字,否则用兜底值。 */
164
+ function num(value, fallback) {
165
+ return Number.isFinite(value) ? value : fallback;
166
+ }
167
+
168
+ /** 去掉末尾斜杠;`baseUrl` 拼路径时不能出现 `//`。 */
169
+ function trimSlash(value) {
170
+ return String(value).replace(/\/+$/, '');
171
+ }
172
+
173
+ /**
174
+ * 从线格式状态名得到内部小写状态:`TASK_STATE_WORKING` → `working`。
175
+ *
176
+ * 内部统一小写,是为了让上层(工具输出、群面板)不用记住 protobuf 的命名习惯;
177
+ * 反向转换由服务端的 `wire.js` 负责,客户端不重复那一半。
178
+ *
179
+ * @param {unknown} wire - 线格式状态名。
180
+ * @returns {string} 内部状态名;未知返回 `'unknown'`。
181
+ */
182
+ function fromWireState(wire) {
183
+ if (typeof wire !== 'string' || wire === '') return 'unknown';
184
+ const bare = wire.startsWith(WIRE_STATE_PREFIX) ? wire.slice(WIRE_STATE_PREFIX.length) : wire;
185
+ return bare.toLowerCase() || 'unknown';
186
+ }
187
+
188
+ /** 线格式状态名(回传给 `stream` 的调用方,便于直接对照协议)。 */
189
+ function toWireState(state) {
190
+ return `${WIRE_STATE_PREFIX}${String(state).toUpperCase()}`;
191
+ }
192
+
193
+ /** 拼 `parts[]` 里的文本;非文本 part(未来可能有 file/data)直接跳过。 */
194
+ function textOfParts(parts) {
195
+ if (!Array.isArray(parts)) return '';
196
+ return parts
197
+ .map((part) => (part !== null && typeof part === 'object' && typeof part.text === 'string' ? part.text : ''))
198
+ .filter((text) => text !== '')
199
+ .join('\n');
200
+ }
201
+
202
+ /**
203
+ * 客户端配置:一元调用、流式重连与卡片缓存共用的那一组旋钮。
204
+ *
205
+ * 单独命名,是因为 `readClientConfig()` 允许调用方直接传 `client` 段——单测和
206
+ * 「只关心客户端」的调用方不必伪造整个配置对象。写成 union 就是把这条已经存在
207
+ * 的运行时判断(`typeof config.client === 'object'`)说清楚。
208
+ *
209
+ * @typedef {object} BridgeClientConfig
210
+ * @property {string} [apiKey] - 上游网关的 Key,一人一把。
211
+ * @property {string} [protocolVersion] - 默认 1.0。
212
+ * @property {string} [forwardProto] - 联调 http 时填 http。
213
+ * @property {number} [cardTtlMs] - 卡片缓存时长。
214
+ * @property {number} [timeoutMs] - 一元调用超时。
215
+ * @property {number} [reconnectAttempts] - 流式断线重试次数。
216
+ */
217
+
218
+ /**
219
+ * `readClientConfig()` 接受的两种形状,合并成一个类型。
220
+ *
221
+ * 写成 `{client?: ...} & BridgeClientConfig` 而不是 union:union 会让「有 client」
222
+ * 那一支上读 `apiKey` 不合法、另一支上读 `client` 不合法,而函数本身是两者都读、
223
+ * 再按运行时判断取舍。类型跟着真实判断走,才不需要在多处重复收窄。
224
+ *
225
+ * @typedef {object} BridgeConfigInput
226
+ * @property {BridgeClientConfig} [client] - 完整配置里的 client 段。
227
+ * @property {string} [apiKey]
228
+ * @property {string} [protocolVersion]
229
+ * @property {string} [forwardProto]
230
+ * @property {number} [cardTtlMs]
231
+ * @property {number} [timeoutMs]
232
+ * @property {number} [reconnectAttempts]
233
+ */
234
+
235
+ /**
236
+ * 一个 A2A 1.0 任务对象,本模块真正会读的那几个字段。
237
+ *
238
+ * 只列读到的字段而非完整 schema:这样「这里读了一个上游不会给的字段」会变成
239
+ * 类型错误,而不是安静地拿到 undefined 再退回空文本。
240
+ *
241
+ * @typedef {object} BridgeTask
242
+ * @property {string} [id]
243
+ * @property {string} [contextId]
244
+ * @property {BridgeTaskStatus} [status]
245
+ * @property {BridgeArtifact[]} [artifacts]
246
+ * @property {object} [metadata]
247
+ */
248
+
249
+ /**
250
+ * @typedef {object} BridgeTaskStatus
251
+ * @property {string} [state] - `TASK_STATE_*`。
252
+ * @property {object} [message] - 状态附带的说明消息。
253
+ * @property {string|number} [timestamp]
254
+ */
255
+
256
+ /**
257
+ * @typedef {object} BridgeArtifact
258
+ * @property {string} [artifactId]
259
+ * @property {string} [name]
260
+ * @property {Array<{kind: string, text?: string}>} [parts]
261
+ */
262
+ /**
263
+ * 从一个任务里取「这次答复」的文本。
264
+ *
265
+ * 优先取 `name === 'reply'` 的 artifact:群技能一次可能回多个 artifact(`posted`、
266
+ * `feed`、`members`…),`reply` 是约定的「给人看的那段」,其余是给程序读的结构。
267
+ *
268
+ * @param {BridgeTask} task - A2A 任务对象。
269
+ * @returns {string} 文本;没有 artifact 时退回任务状态消息里的文本。
270
+ */
271
+ function textOfTask(task) {
272
+ const artifacts = Array.isArray(task?.artifacts) ? task.artifacts : [];
273
+ const preferred = artifacts.find((artifact) => artifact?.name === 'reply') ?? artifacts[0];
274
+ if (preferred !== undefined) return textOfParts(preferred.parts);
275
+ return textOfParts(task?.status?.message?.parts);
276
+ }
277
+
278
+ /** 从一个事件里尽量找出 feed 的 `seq`,供重连时作 `sinceSeq`。 */
279
+ function seqOfEvent(result) {
280
+ const candidates = [
281
+ result?.artifactUpdate?.artifact?.metadata?.seq,
282
+ result?.artifactUpdate?.metadata?.seq,
283
+ result?.artifactUpdate?.seq,
284
+ result?.statusUpdate?.metadata?.seq,
285
+ result?.statusUpdate?.seq,
286
+ result?.metadata?.seq,
287
+ result?.seq,
288
+ ];
289
+ let best;
290
+ for (const candidate of candidates) {
291
+ if (Number.isFinite(candidate)) best = best === undefined ? candidate : Math.max(best, candidate);
292
+ }
293
+ return best;
294
+ }
295
+
296
+ /** 可被 abort 打断的等待。 */
297
+ function sleep(ms, signal) {
298
+ if (!(ms > 0)) return Promise.resolve();
299
+ return new Promise((resolve, reject) => {
300
+ const timer = setTimeout(() => {
301
+ signal?.removeEventListener?.('abort', onAbort);
302
+ resolve();
303
+ }, ms);
304
+ function onAbort() {
305
+ clearTimeout(timer);
306
+ reject(new A2aClientError('network', 'aborted while waiting to reconnect', { code: 'aborted' }));
307
+ }
308
+ if (signal?.aborted === true) {
309
+ clearTimeout(timer);
310
+ reject(new A2aClientError('network', 'aborted while waiting to reconnect', { code: 'aborted' }));
311
+ return;
312
+ }
313
+ signal?.addEventListener?.('abort', onAbort, { once: true });
314
+ });
315
+ }
316
+
317
+ /* -------------------------------------------------------------------------- */
318
+ /* SSE 解析 */
319
+ /* -------------------------------------------------------------------------- */
320
+
321
+ /**
322
+ * 增量 SSE 解析器:喂任意切分的文本,吐出**完整**的 `data` 帧。
323
+ *
324
+ * 为什么需要它:SSE 的一帧可能在任意字节位置被 TCP/网关切开(实测 `data: {"result":`
325
+ * 与 `{"task":…}}` 分成两个 chunk 到达),所以不能「每读到一块就 JSON.parse」。
326
+ * 这里只做分帧,不解释内容:把「一帧」的字符串交给回调,帧内是什么由调用方决定。
327
+ *
328
+ * 按 SSE 规范处理:
329
+ * - 空行才是帧分隔符;`\n` 与 `\r\n` 都认(先剥行尾 `\r`);
330
+ * - `data:` 可多行,用 `\n` 拼(多行 JSON 是合法 SSE);
331
+ * - `:` 开头是注释(网关心跳),忽略但不能当成空帧,否则心跳会把半帧提前截断;
332
+ * - 其它字段(`event:` / `id:` / `retry:`)忽略,不参与拼装;
333
+ * - `flush()` 把末帧交出去:有的网关发完最后一个 `data:` 直接 `end()`,不带空行。
334
+ *
335
+ * @param {(payload: string) => void} onFrame - 收到一个完整帧的 `data` 载荷。
336
+ * @returns {{push: (chunk: string) => void, flush: () => void}} 增量写入接口。
337
+ */
338
+ export function createSseParser(onFrame) {
339
+ let buffer = '';
340
+ let dataLines = [];
341
+
342
+ function dispatch() {
343
+ if (dataLines.length === 0) return;
344
+ const payload = dataLines.join('\n');
345
+ dataLines = [];
346
+ onFrame(payload);
347
+ }
348
+
349
+ function consumeLine(rawLine) {
350
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
351
+ if (line === '') {
352
+ dispatch();
353
+ return;
354
+ }
355
+ if (line.startsWith(':')) return; // 心跳注释
356
+ const colon = line.indexOf(':');
357
+ const field = colon === -1 ? line : line.slice(0, colon);
358
+ let value = colon === -1 ? '' : line.slice(colon + 1);
359
+ if (value.startsWith(' ')) value = value.slice(1);
360
+ if (field === 'data') dataLines.push(value);
361
+ }
362
+
363
+ return {
364
+ push(chunk) {
365
+ buffer += chunk;
366
+ let index = buffer.indexOf('\n');
367
+ while (index !== -1) {
368
+ const rawLine = buffer.slice(0, index);
369
+ buffer = buffer.slice(index + 1);
370
+ consumeLine(rawLine);
371
+ index = buffer.indexOf('\n');
372
+ }
373
+ },
374
+ flush() {
375
+ if (buffer !== '') {
376
+ const rawLine = buffer;
377
+ buffer = '';
378
+ consumeLine(rawLine);
379
+ }
380
+ dispatch();
381
+ },
382
+ };
383
+ }
384
+
385
+ /* -------------------------------------------------------------------------- */
386
+ /* 请求构造 */
387
+ /* -------------------------------------------------------------------------- */
388
+
389
+ /**
390
+ * 读取客户端配置(每次调用现读,让设置页改动立即生效)。
391
+ *
392
+ * 允许直接把 `client` 段传进来:这样单测和「只关心客户端」的调用方不必伪造整个
393
+ * 配置对象。
394
+ *
395
+ * @param {BridgeConfigInput} config - `normalizeConfig()` 的产物,或其 `client` 段。
396
+ * @returns {{apiKey: string, protocolVersion: string, forwardProto: string, cardTtlMs: number, timeoutMs: number, reconnectAttempts: number}}
397
+ */
398
+ function readClientConfig(config) {
399
+ const section = config !== null && typeof config === 'object' && config.client !== null && typeof config.client === 'object'
400
+ ? config.client
401
+ : (config !== null && typeof config === 'object' ? config : {});
402
+ return {
403
+ apiKey: str(section.apiKey),
404
+ protocolVersion: str(section.protocolVersion) || A2A_VERSION,
405
+ forwardProto: str(section.forwardProto),
406
+ cardTtlMs: Math.max(0, num(section.cardTtlMs, DEFAULT_CARD_TTL_MS)),
407
+ timeoutMs: Math.max(0, num(section.timeoutMs, DEFAULT_TIMEOUT_MS)),
408
+ reconnectAttempts: Math.max(0, Math.trunc(num(section.reconnectAttempts, DEFAULT_RECONNECT_ATTEMPTS))),
409
+ };
410
+ }
411
+
412
+ /**
413
+ * 一个对端目标:要么是网关里的 agent,要么是直连的 A2A 端点。
414
+ *
415
+ * 只有 `baseUrl` 必填——其余三项目标里不给就继承客户端配置。把「哪些必须由
416
+ * 调用方提供」写进类型,比在错误信息里补一句更容易被发现。
417
+ *
418
+ * @typedef {object} BridgeTarget
419
+ * @property {string} baseUrl - `http://gw:3000/a2a/<agent>` 或 `http://host:3091/a2a`。
420
+ * @property {string} [agent] - 网关里的 agent 名,拼在 baseUrl 后面。
421
+ * @property {string} [apiKey] - 覆盖客户端配置里的 Key。
422
+ * @property {string} [protocolVersion] - 覆盖客户端配置里的版本。
423
+ * @property {string} [forwardProto] - 覆盖客户端配置里的 X-Forwarded-Proto。
424
+ */
425
+
426
+ /**
427
+ * `send()` 的入参。
428
+ *
429
+ * @typedef {object} BridgeSendInput
430
+ * @property {string} [skill] - `metadata.skill`,缺省 `session/ask`。
431
+ * @property {string} [text] - 消息正文。
432
+ * @property {object} [data] - 结构化参数,进 `metadata.data`。
433
+ * @property {string} [contextId] - 续接已有的 A2A 上下文。
434
+ * @property {number} [timeoutMs] - 覆盖客户端超时。
435
+ * @property {string} [from] - 调用方标识,进 `metadata.from`。
436
+ */
437
+
438
+ /**
439
+ * `stream()` 的入参:在一元入参之上多了订阅位置与取消。
440
+ *
441
+ * @typedef {object} BridgeStreamInput
442
+ * @property {string} [skill]
443
+ * @property {string} [text]
444
+ * @property {object} [data]
445
+ * @property {string} [contextId]
446
+ * @property {number} [sinceSeq] - 断线重连时的续读位置。
447
+ * @property {AbortSignal} [signal] - 取消订阅。
448
+ * @property {(event: object) => void} [onEvent] - 每帧回调。
449
+ * @property {number} [timeoutMs]
450
+ * @property {string} [from]
451
+ */
452
+
453
+ /**
454
+ * 规范化一个 target。
455
+ *
456
+ * `baseUrl` 是唯一必填项。`agent` 可以单独给:网关的基址是 `…/a2a`,具体 agent 是
457
+ * 路径段,登记簿里两者分开存更省事,这里负责拼起来(已经带上了就不重复拼)。
458
+ *
459
+ * @param {BridgeTarget} raw - 调用方给的 target。
460
+ * @param {ReturnType<typeof readClientConfig>} cfg - 客户端配置。
461
+ * @returns {{baseUrl: string, agent: string, apiKey: string, protocolVersion: string, forwardProto: string}}
462
+ * @throws {A2aClientError} `baseUrl` 缺失或不是合法 URL 时(kind `'rpc'`,属于调用方用法错误)。
463
+ */
464
+ function normalizeTarget(raw, cfg) {
465
+ const baseUrl = trimSlash(str(raw?.baseUrl));
466
+ if (baseUrl === '') {
467
+ throw new A2aClientError('rpc', 'a target baseUrl is required (e.g. http://host:3091/a2a)', { code: 'bad_target' });
468
+ }
469
+ let parsed;
470
+ try {
471
+ parsed = new URL(baseUrl);
472
+ } catch {
473
+ throw new A2aClientError('rpc', `target baseUrl is not a valid URL: ${baseUrl}`, { code: 'bad_target' });
474
+ }
475
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
476
+ throw new A2aClientError('rpc', `target baseUrl must be http(s): ${baseUrl}`, { code: 'bad_target' });
477
+ }
478
+ const agent = str(raw?.agent).trim();
479
+ return {
480
+ baseUrl,
481
+ agent,
482
+ apiKey: str(raw?.apiKey) || cfg.apiKey,
483
+ protocolVersion: str(raw?.protocolVersion) || cfg.protocolVersion,
484
+ forwardProto: str(raw?.forwardProto) || cfg.forwardProto,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * JSON-RPC 端点的实际地址:基址 + 可选的 agent 路径段。
490
+ *
491
+ * @param {ReturnType<typeof normalizeTarget>} target - 规范化后的 target。
492
+ * @returns {string} 可 POST 的 URL。
493
+ */
494
+ function endpointOf(target) {
495
+ if (target.agent === '') return target.baseUrl;
496
+ const suffix = `/${encodeURIComponent(target.agent)}`;
497
+ return target.baseUrl.endsWith(suffix) ? target.baseUrl : `${target.baseUrl}${suffix}`;
498
+ }
499
+
500
+ /**
501
+ * 请求头。
502
+ *
503
+ * 三个头都是必需的,各自解决一个真实问题:
504
+ * - `A2A-Version` 缺省会被对端按 0.3 理解(方法名就变成小写管道),所以每次都带;
505
+ * - `x-api-key` 是 AISIX 的门禁;
506
+ * - `X-Forwarded-Proto` 决定网关把卡片里的 url 写成 http 还是 https,本地联调
507
+ * 不带它就会出现「卡片 url 是 https、实际是 http」的偏差。
508
+ *
509
+ * @param {ReturnType<typeof normalizeTarget>} target - 规范化后的 target。
510
+ * @param {'json'|'sse'} mode - 期望的响应类型。
511
+ * @returns {Record<string, string>} 请求头。
512
+ */
513
+ function headersOf(target, mode) {
514
+ const headers = {
515
+ 'content-type': 'application/json',
516
+ accept: mode === 'sse' ? 'text/event-stream, application/json' : 'application/json',
517
+ 'A2A-Version': target.protocolVersion,
518
+ };
519
+ if (target.apiKey !== '') headers['x-api-key'] = target.apiKey;
520
+ if (target.forwardProto !== '') headers['X-Forwarded-Proto'] = target.forwardProto;
521
+ return headers;
522
+ }
523
+
524
+ /**
525
+ * 造 1.0 的信封(CONTRACT §1 的 `params.message` 形状)。
526
+ *
527
+ * `data` 放在 `message.metadata.data` 而不是铺平进 `metadata`:`metadata` 里已经有
528
+ * `skill` 这个协议级字段,业务载荷单独一层才不会跟未来的协议字段撞名。
529
+ * 文本为空时不造 text part——`group/subscribe` 这类「只要流、不带话」的调用本来
530
+ * 就没有正文,塞一个空 part 会让对端的校验器犯难。
531
+ *
532
+ * @param {object} options - 信封参数。
533
+ * @param {string} options.method - `SendMessage` 或 `SendStreamingMessage`。
534
+ * @param {object} options.input - `send`/`stream` 的入参。
535
+ * @param {number} [options.sinceSeq] - 断点续传位置(仅流式)。
536
+ * @returns {object} 可直接 `JSON.stringify` 的 JSON-RPC 请求体。
537
+ */
538
+ function buildEnvelope({ method, input, sinceSeq }) {
539
+ const skill = str(input?.skill).trim() || DEFAULT_SKILL;
540
+ const metadata = { skill };
541
+
542
+ const data = { ...(input?.data !== null && typeof input?.data === 'object' ? input.data : {}) };
543
+ // sinceSeq 是「续到哪」而不是业务数据,但它是这次调用的入参,跟着 data 走对端才读得到。
544
+ if (sinceSeq !== undefined && sinceSeq !== null) data.sinceSeq = sinceSeq;
545
+ if (Object.keys(data).length > 0) metadata.data = data;
546
+
547
+ const message = {
548
+ role: 'user',
549
+ messageId: randomUUID(),
550
+ parts: [],
551
+ metadata,
552
+ };
553
+ const text = str(input?.text);
554
+ if (text !== '') message.parts.push({ kind: 'text', text });
555
+ if (str(input?.contextId) !== '') message.contextId = str(input.contextId);
556
+
557
+ return { jsonrpc: '2.0', id: randomUUID(), method, params: { message } };
558
+ }
559
+
560
+ /* -------------------------------------------------------------------------- */
561
+ /* 错误归类 */
562
+ /* -------------------------------------------------------------------------- */
563
+
564
+ /**
565
+ * 把 HTTP 状态码归类。
566
+ *
567
+ * 401/403 分开是因为处置方式完全不同:401 是「补 key / 换 key」,403 是「找管理员
568
+ * 开 allowed_agents 的 ACL」。合成一个 auth 会让人白换一遍 key。
569
+ *
570
+ * @param {Response} response - fetch 响应。
571
+ * @param {string} url - 请求地址(不含凭证,可安全进错误信息)。
572
+ * @returns {Promise<A2aClientError>} 归类后的错误。
573
+ */
574
+ async function httpErrorOf(response, url) {
575
+ let body = '';
576
+ try {
577
+ body = (await response.text()).slice(0, MAX_ERROR_BODY_CHARS);
578
+ } catch {
579
+ body = '';
580
+ }
581
+ const status = response.status;
582
+ const suffix = body === '' ? '' : ` — ${body}`;
583
+ if (status === 401) {
584
+ return new A2aClientError('auth', `upstream rejected the credential (HTTP 401 from ${url})${suffix}`, { status, code: 401, data: body });
585
+ }
586
+ if (status === 403) {
587
+ return new A2aClientError('permission', `upstream denied this caller (HTTP 403 from ${url})${suffix}`, { status, code: 403, data: body });
588
+ }
589
+ return new A2aClientError('rpc', `upstream returned HTTP ${status} from ${url}${suffix}`, { status, code: status, data: body });
590
+ }
591
+
592
+ /**
593
+ * 把 JSON-RPC 的 `error` 归类。
594
+ *
595
+ * @param {object} error - 响应里的 `error` 对象。
596
+ * @param {string} url - 请求地址。
597
+ * @returns {A2aClientError} 归类后的错误。
598
+ */
599
+ function rpcErrorOf(error, url) {
600
+ const code = Number.isFinite(error?.code) ? error.code : undefined;
601
+ const kind = RPC_ERROR_KINDS.get(code) ?? 'rpc';
602
+ const detail = str(error?.message) || 'unspecified JSON-RPC error';
603
+ const business = str(error?.data?.code);
604
+ const where = url === '' ? 'upstream' : `upstream ${url}`;
605
+ return new A2aClientError(kind, `${where} rejected the call: ${detail} (code ${code ?? '?'}${business === '' ? '' : `, ${business}`})`, {
606
+ code,
607
+ data: business === '' ? undefined : business,
608
+ });
609
+ }
610
+
611
+ /**
612
+ * 把 fetch 抛出的异常归一成 network 类。
613
+ *
614
+ * 超时与主动取消要分开:前者值得重试,后者是调用方自己不想等了,重试是错的。
615
+ *
616
+ * @param {unknown} err - fetch 抛出的异常。
617
+ * @param {string} url - 请求地址。
618
+ * @returns {A2aClientError} 归类后的错误。
619
+ */
620
+ function networkErrorOf(err, url) {
621
+ const name = err?.name;
622
+ if (name === 'TimeoutError') {
623
+ return new A2aClientError('network', `request to ${url} timed out`, { code: 'timeout', cause: err });
624
+ }
625
+ if (name === 'AbortError') {
626
+ return new A2aClientError('network', `request to ${url} was aborted by the caller`, { code: 'aborted', cause: err });
627
+ }
628
+ const cause = err?.cause;
629
+ const detail = cause?.code ?? cause?.message ?? err?.message ?? 'unknown error';
630
+ return new A2aClientError('network', `cannot reach ${url}: ${detail}`, { code: typeof cause?.code === 'string' ? cause.code : undefined, cause: err });
631
+ }
632
+
633
+ /* -------------------------------------------------------------------------- */
634
+ /* bridge */
635
+ /* -------------------------------------------------------------------------- */
636
+
637
+ /**
638
+ * 造一个 A2A 客户端。
639
+ *
640
+ * 一个实例持有一份卡片缓存。缓存放在实例上(而不是模块级)是为了让「多套配置」与
641
+ * 单测互不串味:`invalidate()` 清掉的必须只是自己这份。
642
+ *
643
+ * @param {object} options - 依赖注入。
644
+ * @param {{warn?: Function, info?: Function, debug?: Function, error?: Function}} [options.logger] - 日志器(可缺省,缺省静默)。
645
+ * @param {object} [options.config] - `normalizeConfig()` 的产物。
646
+ * @param {() => number} [options.clock] - 时钟注入,单测用来快进 TTL。
647
+ * @returns {{
648
+ * discover: (target: object) => Promise<object>,
649
+ * send: (target: object, input?: object) => Promise<object>,
650
+ * stream: (target: object, input?: object) => Promise<object>,
651
+ * invalidate: (target: object|string) => void,
652
+ * invalidateAll: () => void
653
+ * }}
654
+ */
655
+ export function createBridge({ logger, config, clock } = {}) {
656
+ const now = typeof clock === 'function' ? clock : () => Date.now();
657
+ /** @type {Map<string, {card: object, at: number}>} */
658
+ const cards = new Map();
659
+ /** 同一组 (baseUrl, agent, cardUrl) 的偏差只提醒一次,避免每条消息刷一行日志。 */
660
+ const warned = new Set();
661
+
662
+ function cfg() {
663
+ return readClientConfig(config);
664
+ }
665
+
666
+ function warn(message) {
667
+ if (typeof logger?.warn === 'function') logger.warn(`[a2a-bridge] ${message}`);
668
+ }
669
+
670
+ function cacheKeyOf(target) {
671
+ return target.agent === '' ? target.baseUrl : `${target.baseUrl}#${target.agent}`;
672
+ }
673
+
674
+ /**
675
+ * 卡片 url 与配置 baseUrl 冲突时的收敛。
676
+ *
677
+ * 只处理一种偏差:对端声称 https、我们用的是 http 且主机相同(AISIX 在缺
678
+ * `X-Forwarded-Proto` 时的典型表现)。主机都不一样的「偏差」不猜:那可能是网关
679
+ * 改写成了对外域名,我们无权替它改,只 warn。
680
+ *
681
+ * @param {object} card - 上游给的卡片。
682
+ * @param {ReturnType<typeof normalizeTarget>} target - 规范化后的 target。
683
+ * @returns {object} 修正后的卡片(`url` 指向真正能用的地址)。
684
+ */
685
+ function reconcileCard(card, target) {
686
+ const cardUrl = str(card?.url);
687
+ if (cardUrl === '') return card;
688
+ let claimed;
689
+ try {
690
+ claimed = new URL(cardUrl);
691
+ } catch {
692
+ return card;
693
+ }
694
+ let actual;
695
+ try {
696
+ actual = new URL(endpointOf(target));
697
+ } catch {
698
+ return card;
699
+ }
700
+ if (cardUrl === target.baseUrl) return card;
701
+
702
+ const schemeOnly = claimed.protocol === 'https:' && actual.protocol === 'http:' && claimed.hostname === actual.hostname;
703
+ const marker = `${target.baseUrl}|${cardUrl}`;
704
+ if (!schemeOnly) {
705
+ if (!warned.has(marker)) {
706
+ warned.add(marker);
707
+ warn(`agent card claims url=${cardUrl}, but baseUrl=${target.baseUrl} is what we will call; keeping baseUrl`);
708
+ }
709
+ return card;
710
+ }
711
+ if (!warned.has(marker)) {
712
+ warned.add(marker);
713
+ warn(`agent card claims url=${cardUrl} (https) while baseUrl=${target.baseUrl} is http; `
714
+ + 'following baseUrl — set client.forwardProto="http" so the gateway advertises the right scheme');
715
+ }
716
+ return { ...card, url: target.baseUrl };
717
+ }
718
+
719
+ /**
720
+ * 取对端 Agent Card(带 TTL 缓存)。
721
+ *
722
+ * @param {object} rawTarget - `{baseUrl, agent?, apiKey?, protocolVersion?, forwardProto?}`。
723
+ * @returns {Promise<object>} 卡片对象(`url` 已按实际可达地址收敛)。
724
+ * @throws {A2aClientError} 凭证/权限/网络/协议错误。
725
+ */
726
+ async function discover(rawTarget) {
727
+ const options = cfg();
728
+ const target = normalizeTarget(rawTarget, options);
729
+ const key = cacheKeyOf(target);
730
+ const cached = cards.get(key);
731
+ if (cached !== undefined && now() - cached.at < options.cardTtlMs) {
732
+ return cached.card;
733
+ }
734
+
735
+ const url = `${endpointOf(target)}/.well-known/agent-card.json`;
736
+ let response;
737
+ try {
738
+ response = await fetch(url, {
739
+ method: 'GET',
740
+ headers: headersOf(target, 'json'),
741
+ signal: AbortSignal.timeout(options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_TIMEOUT_MS),
742
+ });
743
+ } catch (err) {
744
+ throw networkErrorOf(err, url);
745
+ }
746
+ if (!response.ok) throw await httpErrorOf(response, url);
747
+
748
+ const text = await response.text();
749
+ let card;
750
+ try {
751
+ card = JSON.parse(text);
752
+ } catch {
753
+ throw new A2aClientError('rpc', `agent card at ${url} is not valid JSON`, { data: text.slice(0, MAX_ERROR_BODY_CHARS) });
754
+ }
755
+ if (card === null || typeof card !== 'object') {
756
+ throw new A2aClientError('rpc', `agent card at ${url} is not a JSON object`);
757
+ }
758
+
759
+ const declared = str(card.protocolVersion);
760
+ if (declared !== '' && declared !== target.protocolVersion) {
761
+ warn(`agent card declares protocolVersion=${declared} but this client speaks ${target.protocolVersion}`);
762
+ }
763
+
764
+ const reconciled = reconcileCard(card, target);
765
+ cards.set(key, { card: reconciled, at: now() });
766
+ return reconciled;
767
+ }
768
+
769
+ /**
770
+ * 发一条消息并等结论(JSON-RPC `SendMessage`)。
771
+ *
772
+ * HTTP 超时的取法:显式 `input.timeoutMs` 优先;否则若技能自己带了 `data.timeoutMs`
773
+ * 预算(`session/ask` 会带),就比它多留 5s——否则会出现「对端还在干活、客户端先
774
+ * 超时」这种假失败;都没有才用 `config.client.timeoutMs`。
775
+ *
776
+ * @param {object} rawTarget - target。
777
+ * @param {object} [input] - `{skill, text, data, contextId, timeoutMs}`。
778
+ * @returns {Promise<{taskId: string|undefined, state: string, wireState: string, text: string, contextId: string|undefined, artifacts: Array<object>, raw: object}>}
779
+ * @throws {A2aClientError} 凭证/权限/版本/网络/JSON-RPC 错误。
780
+ */
781
+ async function send(rawTarget, input = {}) {
782
+ const options = cfg();
783
+ const target = normalizeTarget(rawTarget, options);
784
+ const url = endpointOf(target);
785
+ const envelope = buildEnvelope({ method: 'SendMessage', input, sinceSeq: undefined });
786
+
787
+ const ownBudget = Number.isFinite(input?.data?.timeoutMs) ? input.data.timeoutMs : undefined;
788
+ const timeoutMs = Number.isFinite(input?.timeoutMs)
789
+ ? input.timeoutMs
790
+ : (ownBudget === undefined ? options.timeoutMs : ownBudget + 5000);
791
+
792
+ let response;
793
+ try {
794
+ response = await fetch(url, {
795
+ method: 'POST',
796
+ headers: headersOf(target, 'json'),
797
+ body: JSON.stringify(envelope),
798
+ signal: AbortSignal.timeout(timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS),
799
+ });
800
+ } catch (err) {
801
+ throw networkErrorOf(err, url);
802
+ }
803
+ if (!response.ok) throw await httpErrorOf(response, url);
804
+
805
+ const text = await response.text();
806
+ let body;
807
+ try {
808
+ body = JSON.parse(text);
809
+ } catch {
810
+ throw new A2aClientError('rpc', `upstream ${url} returned a non-JSON body`, { data: text.slice(0, MAX_ERROR_BODY_CHARS) });
811
+ }
812
+ if (body?.error !== undefined && body.error !== null) throw rpcErrorOf(body.error, url);
813
+
814
+ const result = body?.result ?? {};
815
+ const task = result.task;
816
+ const message = result.message;
817
+ // `result.task` 是一元主路径;`result.message` 是「对端直接回话、还没成任务」的兼容路径。
818
+ const state = task !== undefined ? fromWireState(task?.status?.state) : 'completed';
819
+ const artifacts = Array.isArray(task?.artifacts) ? task.artifacts : [];
820
+ return {
821
+ taskId: task?.id ?? undefined,
822
+ state,
823
+ wireState: toWireState(state),
824
+ text: task !== undefined ? textOfTask(task) : textOfParts(message?.parts),
825
+ contextId: task?.contextId ?? message?.contextId ?? undefined,
826
+ artifacts,
827
+ // 技能返回的结构化结果放在 `task.metadata.result`:`group/join` 要交回成员凭证、
828
+ // `group/read` 要交回条目,这些都不该塞进文本 artifact 让调用方再解析一遍。
829
+ // 调用方的信封(`skill`/`data`)保持原样,所以这里只取 `result` 这一层。
830
+ data: task?.metadata?.result,
831
+ raw: body,
832
+ };
833
+ }
834
+
835
+ /**
836
+ * 订阅一条流(JSON-RPC `SendStreamingMessage`)。
837
+ *
838
+ * 重连只在「调用方给了 `sinceSeq`」时发生:那表示这条流有可续的语义(群 feed),
839
+ * 断了从断点补即可;没给 `sinceSeq` 的流(一次性长任务)重连会把已完成的事件
840
+ * 再放一遍,所以宁可把异常 EOF 如实抛给调用方。
841
+ *
842
+ * @param {object} rawTarget - target。
843
+ * @param {object} [input] - `{skill, text, data, sinceSeq, signal, onEvent, timeoutMs}`。
844
+ * `timeoutMs` 为**空闲**超时(每收到一块数据就重置),默认 0 = 不限,因为订阅
845
+ * 本来就是长连。`onEvent` 收到 `{type:'status'|'artifact'|'event'|'reconnect'}`。
846
+ * @returns {Promise<{taskId: string|undefined, state: string, wireState: string, text: string, final: boolean, reconnects: number, lastSeq: (number|undefined)}>}
847
+ * @throws {A2aClientError} 凭证/权限/版本/网络/JSON-RPC 错误。
848
+ */
849
+ async function stream(rawTarget, input = {}) {
850
+ const options = cfg();
851
+ const target = normalizeTarget(rawTarget, options);
852
+ const url = endpointOf(target);
853
+ const onEvent = typeof input.onEvent === 'function' ? input.onEvent : () => {};
854
+ const signal = input.signal;
855
+ // 「可续」的唯一判据:调用方自己说了从哪开始。没给 sinceSeq 的流是一次性长任务,
856
+ // 重连只会把已投过的进度再投一遍,所以不重连(见下方 reconnect 分支)。
857
+ const resumable = input.sinceSeq !== undefined && input.sinceSeq !== null;
858
+
859
+ // 跨重连保留:重连补的是「新事件」,已经收到的文本不该被清空。
860
+ const segments = new Map();
861
+ let taskId;
862
+ let state = 'submitted';
863
+ let final = false;
864
+ let attempts = 0;
865
+ let lastSeqSeen = resumable ? input.sinceSeq : undefined;
866
+
867
+ /**
868
+ * 把事件交给调用方。
869
+ *
870
+ * 调用方的回调抛错时必须**立刻、原样**抛出去,不能被当成「连接断了」而触发重连:
871
+ * 重连会把已经投过的事件再投一遍,调用方(群收件人)就会看到重复消息。
872
+ * 用 `EventDeliveryError` 标记,让 `once` 的 catch 认出来直接放行。
873
+ */
874
+ function emit(event) {
875
+ try {
876
+ onEvent(event);
877
+ } catch (err) {
878
+ throw new EventDeliveryError(err);
879
+ }
880
+ }
881
+
882
+ /** 记下见到过的最新 seq,重连时用它当 sinceSeq。 */
883
+ function noteSeq(value) {
884
+ if (!Number.isFinite(value)) return;
885
+ if (lastSeqSeen === undefined || value > lastSeqSeen) lastSeqSeen = value;
886
+ }
887
+
888
+ /** 把已收到的 artifact 文本拼成最终答复(优先 `reply`,与 `send` 的取法一致)。 */
889
+ function collectedText() {
890
+ const preferred = segments.get('reply');
891
+ if (preferred !== undefined) return preferred;
892
+ return [...segments.values()].join('\n');
893
+ }
894
+
895
+ /** 从一次连接里抽事件;可重试的失败放进 `outcome.failure`,不可重试的直接抛。 */
896
+ async function once(sinceSeq) {
897
+ const envelope = buildEnvelope({ method: 'SendStreamingMessage', input, sinceSeq });
898
+
899
+ // 空闲超时:只统计「多久没收到字节」,不是总时长——订阅本来就该长连,
900
+ // 用总时长会把一条健康的空闲订阅判死。0/未设 = 不限。
901
+ //
902
+ // 这个 controller 必须**同时**管住 fetch 与读循环:只在一个地方 abort 而信号没
903
+ // 接上去,就是一个永远不响的定时器(这正是最初的实现踩到的坑——不设超时的
904
+ // 订阅会一直挂着,直到对端自己关连接)。
905
+ const idleBudget = Number.isFinite(input.timeoutMs) && input.timeoutMs > 0 ? input.timeoutMs : 0;
906
+ const idleController = new AbortController();
907
+ const fetchSignal = signal === undefined
908
+ ? idleController.signal
909
+ : AbortSignal.any([signal, idleController.signal]);
910
+
911
+ let response;
912
+ try {
913
+ response = await fetch(url, {
914
+ method: 'POST',
915
+ headers: headersOf(target, 'sse'),
916
+ body: JSON.stringify(envelope),
917
+ signal: fetchSignal,
918
+ });
919
+ } catch (err) {
920
+ if (idleController.signal.aborted && signal?.aborted !== true) {
921
+ throw new A2aClientError('network', `stream from ${url} did not respond within ${idleBudget}ms`, { code: 'idle_timeout', cause: err });
922
+ }
923
+ throw networkErrorOf(err, url);
924
+ }
925
+ if (!response.ok) throw await httpErrorOf(response, url);
926
+
927
+ const outcome = { final: false, sawDone: false, failure: undefined };
928
+
929
+ /**
930
+ * 解释一帧。JSON-RPC 层的错误在这里抛(不能重试的那种),
931
+ * 单帧 JSON 坏了只 warn——一条坏帧不该让整条订阅断掉。
932
+ */
933
+ function onFrame(payload) {
934
+ const trimmed = payload.trim();
935
+ if (trimmed === '') return;
936
+ // 非 A2A 标准,但有些网关/中间层会发它当结束标记:容忍,并当成流结束。
937
+ if (trimmed === '[DONE]') {
938
+ outcome.sawDone = true;
939
+ return;
940
+ }
941
+ let frame;
942
+ try {
943
+ frame = JSON.parse(trimmed);
944
+ } catch {
945
+ warn(`ignoring a malformed SSE frame from ${url}: ${trimmed.slice(0, 120)}`);
946
+ return;
947
+ }
948
+ if (frame?.error !== undefined && frame.error !== null) throw rpcErrorOf(frame.error, url);
949
+
950
+ const result = frame?.result ?? {};
951
+ noteSeq(seqOfEvent(result));
952
+
953
+ const statusUpdate = result.statusUpdate;
954
+ if (statusUpdate !== undefined) {
955
+ taskId = statusUpdate.taskId ?? taskId;
956
+ const nextState = fromWireState(statusUpdate.status?.state);
957
+ if (nextState !== 'unknown') state = nextState;
958
+ // `final:true` 必须落在终止态上才算结束(见 TERMINAL_STATES 的注释):
959
+ // 一个声称 final 的 submitted 帧是坏帧,如实上报但不据此收线。
960
+ const claimsFinal = statusUpdate.final === true;
961
+ const isTerminal = claimsFinal && TERMINAL_STATES.has(state);
962
+ if (claimsFinal && !isTerminal) {
963
+ warn(`stream from ${url} sent final:true on a non-terminal state "${state}"; continuing`);
964
+ }
965
+ if (isTerminal) {
966
+ outcome.final = true;
967
+ final = true;
968
+ }
969
+ emit({
970
+ type: 'status',
971
+ taskId,
972
+ state,
973
+ wireState: toWireState(state),
974
+ // 对外只报「这次流真的结束了」,而不是照抄对端那个可能自相矛盾的标记。
975
+ final: isTerminal,
976
+ note: textOfParts(statusUpdate.status?.message?.parts),
977
+ raw: statusUpdate,
978
+ });
979
+ return;
980
+ }
981
+
982
+ const artifactUpdate = result.artifactUpdate;
983
+ if (artifactUpdate !== undefined) {
984
+ taskId = artifactUpdate.taskId ?? taskId;
985
+ const artifact = artifactUpdate.artifact ?? {};
986
+ const key = str(artifact.artifactId) || str(artifact.name) || 'default';
987
+ const chunk = textOfParts(artifact.parts);
988
+ // append=false 表示「这份 artifact 从这一帧开始」,所以替换而不是追加;
989
+ // 长文本分片则靠 append=true 逐段拼,两者都能落在同一个 Map 里。
990
+ if (artifactUpdate.append === true) segments.set(key, (segments.get(key) ?? '') + chunk);
991
+ else segments.set(key, chunk);
992
+ emit({
993
+ type: 'artifact',
994
+ taskId,
995
+ artifactId: key,
996
+ name: str(artifact.name),
997
+ text: chunk,
998
+ append: artifactUpdate.append === true,
999
+ lastChunk: artifactUpdate.lastChunk === true,
1000
+ raw: artifactUpdate,
1001
+ });
1002
+ return;
1003
+ }
1004
+
1005
+ const task = result.task;
1006
+ if (task !== undefined) {
1007
+ taskId = task.id ?? taskId;
1008
+ const nextState = fromWireState(task.status?.state);
1009
+ if (nextState !== 'unknown') state = nextState;
1010
+ emit({ type: 'task', taskId, state, wireState: toWireState(state), raw: task });
1011
+ return;
1012
+ }
1013
+ emit({ type: 'event', taskId, raw: frame });
1014
+ }
1015
+
1016
+ try {
1017
+ const contentType = str(response.headers.get('content-type'));
1018
+ if (contentType.includes('application/json')) {
1019
+ // 少数网关会把流式塌缩成一次性 JSON;照一元结果处理,别让调用方
1020
+ // 误以为「流里没事件」是网络问题。
1021
+ const body = JSON.parse(await response.text());
1022
+ if (body?.error !== undefined && body.error !== null) throw rpcErrorOf(body.error, url);
1023
+ const task = body?.result?.task;
1024
+ if (task !== undefined) {
1025
+ taskId = task.id ?? taskId;
1026
+ state = fromWireState(task.status?.state);
1027
+ const artifact = (Array.isArray(task.artifacts) ? task.artifacts : [])[0];
1028
+ if (artifact !== undefined) {
1029
+ segments.set(str(artifact.artifactId) || 'default', textOfParts(artifact.parts));
1030
+ emit({ type: 'artifact', taskId, artifactId: str(artifact.artifactId) || 'default', name: str(artifact.name), text: textOfParts(artifact.parts), append: false, lastChunk: true, raw: artifact });
1031
+ }
1032
+ }
1033
+ outcome.final = true;
1034
+ final = true;
1035
+ return outcome;
1036
+ }
1037
+
1038
+ if (response.body === null || response.body === undefined) {
1039
+ throw new A2aClientError('network', `upstream ${url} returned no response body for a streaming call`, { code: 'no_body' });
1040
+ }
1041
+
1042
+ // Node 22 的 fetch 返回 web ReadableStream,chunk 是 Uint8Array。
1043
+ // 必须 stream:true 解码:中文一个字符 3 字节,可能正好被切在两个 chunk 之间。
1044
+ const decoder = new TextDecoder('utf-8');
1045
+ const parser = createSseParser(onFrame);
1046
+ let idleTimer;
1047
+ const armIdle = () => {
1048
+ if (idleBudget === 0) return;
1049
+ clearTimeout(idleTimer);
1050
+ idleTimer = setTimeout(() => idleController.abort(), idleBudget);
1051
+ };
1052
+ armIdle();
1053
+
1054
+ try {
1055
+ for await (const chunk of response.body) {
1056
+ const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
1057
+ if (text !== '') {
1058
+ parser.push(text);
1059
+ armIdle();
1060
+ }
1061
+ // 收到 final / [DONE] 就主动收线,避免对着一个不结束的连接白等。
1062
+ if (outcome.final || outcome.sawDone) {
1063
+ if (typeof response.body.cancel === 'function') void response.body.cancel().catch(() => {});
1064
+ break;
1065
+ }
1066
+ }
1067
+ } catch (err) {
1068
+ // 空闲超时与调用方取消要分开:前者可重连,后者必须立刻停。
1069
+ if (idleController.signal.aborted && signal?.aborted !== true) {
1070
+ throw new A2aClientError('network', `stream from ${url} was idle for ${idleBudget}ms`, { code: 'idle_timeout', cause: err });
1071
+ }
1072
+ throw err;
1073
+ } finally {
1074
+ clearTimeout(idleTimer);
1075
+ }
1076
+ // 冲刷解码器与末帧:可能是被切断的多字节字符,也可能是没有空行结尾的最后一帧。
1077
+ const tail = decoder.decode();
1078
+ if (tail !== '') parser.push(tail);
1079
+ parser.flush();
1080
+ } catch (err) {
1081
+ // 调用方回调抛的错:保留包装继续上抛,由最外层的 `stream` 解包。
1082
+ // 在这里解包会被下一层当成网络错误重新归类(进而重连重投)。
1083
+ if (err instanceof EventDeliveryError) throw err;
1084
+ // 凭证/权限/版本/协议错误不重试:重试只会拿到同一个错误。
1085
+ if (err instanceof A2aClientError && err.kind !== 'network') throw err;
1086
+ if (signal?.aborted === true) throw networkErrorOf(err, url);
1087
+ outcome.failure = err;
1088
+ }
1089
+ return outcome;
1090
+ }
1091
+
1092
+ for (;;) {
1093
+ // 一次连接上的失败有两种形态:`once` 直接把网络错误抛出来(连接在拿到响应头
1094
+ // 之前就断了),或把可重试的失败放进 `outcome.failure`(拿满响应头、读到一半
1095
+ // 断)。两者的重试语义必须一样,否则「网关重启」这种最常见的抖动会时好时坏。
1096
+ let outcome;
1097
+ let failure;
1098
+ try {
1099
+ outcome = await once(resumable ? lastSeqSeen : undefined);
1100
+ failure = outcome.failure;
1101
+ } catch (err) {
1102
+ // 调用方回调抛的错:不是这条流的问题,立刻原样上抛。
1103
+ // 绝不能落进下面的 network 归类——那会触发重连,把已投过的事件再投一遍。
1104
+ if (err instanceof EventDeliveryError) throw err.cause;
1105
+ // 只有网络类错误值得重试;凭证/权限/版本类的错误无论如何都是同一个答案。
1106
+ const normalized = err instanceof A2aClientError ? err : networkErrorOf(err, url);
1107
+ if (normalized.kind !== 'network') throw normalized;
1108
+ if (signal?.aborted === true) throw normalized;
1109
+ failure = normalized;
1110
+ }
1111
+
1112
+ if (failure === undefined) {
1113
+ if (outcome === undefined || outcome.final || outcome.sawDone) break;
1114
+ break; // 正常 EOF 但没 final:交给调用方判断
1115
+ }
1116
+ if (outcome?.final === true || outcome?.sawDone === true) break;
1117
+ if (!resumable) throw failure; // 不可续的流重连 = 同一批事件投两遍
1118
+ if (attempts >= options.reconnectAttempts) throw failure;
1119
+
1120
+ const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempts, RECONNECT_MAX_DELAY_MS);
1121
+ attempts += 1;
1122
+ emit({ type: 'reconnect', attempt: attempts, delayMs: delay, sinceSeq: lastSeqSeen });
1123
+ warn(`stream from ${url} ended abnormally; reconnecting (attempt ${attempts}/${options.reconnectAttempts}) after ${delay}ms`);
1124
+ await sleep(delay, signal);
1125
+ }
1126
+
1127
+ return {
1128
+ taskId,
1129
+ state,
1130
+ wireState: toWireState(state),
1131
+ text: collectedText(),
1132
+ final,
1133
+ reconnects: attempts,
1134
+ lastSeq: lastSeqSeen,
1135
+ };
1136
+ }
1137
+
1138
+ return {
1139
+ discover,
1140
+ send,
1141
+ stream,
1142
+ /**
1143
+ * 丢掉一个 target 的卡片缓存(改配置、换 key、对端升级后立刻重取)。
1144
+ * @param {object|string} rawTarget - target 或裸 baseUrl。
1145
+ * @returns {void}
1146
+ */
1147
+ invalidate(rawTarget) {
1148
+ if (typeof rawTarget === 'string') {
1149
+ const base = trimSlash(rawTarget);
1150
+ for (const key of [...cards.keys()]) {
1151
+ if (key === base || key.startsWith(`${base}#`)) cards.delete(key);
1152
+ }
1153
+ return;
1154
+ }
1155
+ try {
1156
+ const options = cfg();
1157
+ cards.delete(cacheKeyOf(normalizeTarget(rawTarget, options)));
1158
+ } catch {
1159
+ // 连 target 都不合法时无所谓缓存,静默即可——调用方紧接着会收到正式报错。
1160
+ }
1161
+ },
1162
+ /** 清空全部卡片缓存。 */
1163
+ invalidateAll() {
1164
+ cards.clear();
1165
+ },
1166
+ };
1167
+ }