@evomap/evolver-adapter-public 2.0.0-beta.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,899 @@
1
+ import { hub as hubNs } from '@evomap/evolver-core';
2
+ import { AuthError, HubFetch, HubClientError, isHubUnreachableError } from './hubFetch.js';
3
+ import { isNodeSecret, parseNodeSecretVersion } from './auth/legacyShim.js';
4
+ import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire } from './wireMap.js';
5
+ import { antiAbuseTelemetryMode, buildHeartbeatAntiAbuseTelemetry, } from './antiAbuseTelemetry.js';
6
+ export const INBOUND_LIMIT = 100;
7
+ export const OUTBOUND_MAX_BATCH = 50;
8
+ export const OUTBOUND_MAX_BODY_BYTES = 4 * 1024 * 1024;
9
+ export const PUBLIC_PROTOCOL_VERSION = 'gep-a2a/1.0.0';
10
+ export const PUBLIC_HUB_CAPABILITIES = ['publish', 'fetch', 'search', 'task', 'mailbox', 'auth', 'marketplace', 'economy', 'questions', 'recipes'];
11
+ const QUESTION_SUBMIT_FAST_PATH_BYPASS_CONTENT_HASH = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
12
+ const DRY_RUN_RECIPE_ID = 'dry-run-recipe';
13
+ const HUB_DRY_RUN_VALUES = new Set(['1', 'true', 'yes', 'on']);
14
+ // Mirror the hub's /a2a/memory/record input bounds so an oversized claim list is
15
+ // trimmed client-side instead of being silently dropped server-side: what we SEND
16
+ // equals what the hub will KEEP.
17
+ export const USED_ASSET_IDS_MAX = 50;
18
+ export const USED_ASSET_ID_MAX_LEN = 200;
19
+ /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
20
+ export function gepEnvelope(messageType, payload) {
21
+ return {
22
+ protocol: 'gep-a2a', protocol_version: '1.0.0', message_type: messageType,
23
+ message_id: `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
24
+ timestamp: new Date().toISOString(), payload,
25
+ };
26
+ }
27
+ // v1 a2aProtocol.js L1999-2003: the three app-level rejection reasons that mean
28
+ // our cached node_secret has DIVERGED from the hub's record (hub-side reset,
29
+ // restored-from-backup machine, manual unlink) — not a transport/generic failure.
30
+ // Retrying with a diverged secret can never succeed; the only recovery is to drop
31
+ // the local secret and re-hello unauthenticated.
32
+ const NODE_SECRET_DIVERGENCE_REASONS = ['node_secret_invalid', 'rotation_requires_current_secret', 'invalid_secret'];
33
+ /**
34
+ * Detect the hub's HTTP-200 app-level secret-divergence rejection. v1 keys on
35
+ * status:"rejected" (top-level OR payload) AND a reason containing one of the
36
+ * three divergence markers. Mirrors a2aProtocol.js L1993-2003.
37
+ */
38
+ function isSecretDivergenceRejection(body, payload) {
39
+ const rejected = body['status'] === 'rejected' || payload['status'] === 'rejected';
40
+ if (!rejected)
41
+ return false;
42
+ const reason = String(payload['reason'] ?? body['reason'] ?? '').toLowerCase();
43
+ return NODE_SECRET_DIVERGENCE_REASONS.some((marker) => reason.includes(marker));
44
+ }
45
+ export function isHubDryRunEnabled(env = process.env) {
46
+ return HUB_DRY_RUN_VALUES.has(String(env['HUB_DRY_RUN'] ?? '').trim().toLowerCase());
47
+ }
48
+ export function outboundMaxBodyBytes(env = process.env) {
49
+ for (const key of [
50
+ 'EVOLVER_HUB_MAILBOX_OUTBOUND_MAX_BODY_BYTES',
51
+ 'EVOMAP_OUTBOUND_SYNC_MAX_BODY_BYTES',
52
+ 'EVOMAP_MAILBOX_OUTBOUND_MAX_BODY_BYTES',
53
+ ]) {
54
+ const raw = Number(env[key]);
55
+ if (Number.isSafeInteger(raw) && raw > 0)
56
+ return raw;
57
+ }
58
+ return OUTBOUND_MAX_BODY_BYTES;
59
+ }
60
+ function traceOutboundMaxBodyBytes(env = process.env) {
61
+ const mailboxLimit = outboundMaxBodyBytes(env);
62
+ for (const key of [
63
+ 'EVOLVER_LLM_TRACE_MAX_UPLOAD_BYTES',
64
+ 'EVOMAP_PROXY_TRACE_MAX_UPLOAD_BYTES',
65
+ 'EVOLVER_LLM_TRACE_ENVELOPE_MAX_CHARS',
66
+ 'EVOMAP_PROXY_TRACE_ENVELOPE_MAX_BYTES',
67
+ ]) {
68
+ const raw = Number(env[key]);
69
+ if (Number.isSafeInteger(raw) && raw > 0)
70
+ return Math.min(raw, mailboxLimit);
71
+ }
72
+ return mailboxLimit;
73
+ }
74
+ /**
75
+ * 公版 hub 的 HubCapability 实现(M6-6). 打 /a2a/{publish,fetch,mailbox/*,events/poll}.
76
+ * 唯一懂公版 wire shape 的地方; 经 wireMap 规约成 core 类型. 真链路冒烟在 M6-7(dev.evomap.ai).
77
+ */
78
+ export class PublicHubCapability {
79
+ opts;
80
+ http;
81
+ auth;
82
+ recipes = {
83
+ create: async (request) => this.createRecipe(request),
84
+ publish: async (recipeId) => this.publishRecipe(recipeId),
85
+ get: async (recipeId) => this.getRecipe(recipeId),
86
+ express: async (recipeId, request = {}) => this.expressRecipe(recipeId, request),
87
+ };
88
+ constructor(opts) {
89
+ this.opts = opts;
90
+ this.auth = opts.auth;
91
+ this.http = new HubFetch({ baseUrl: opts.baseUrl, auth: opts.auth, fetchFn: opts.fetchFn, senderId: opts.senderId });
92
+ }
93
+ async hello(opts) {
94
+ try {
95
+ const sender = this.opts.senderId();
96
+ const body = await this.http.call('POST', '/a2a/hello', gepEnvelope('hello', {
97
+ rotate_secret: opts.rotate,
98
+ capabilities: { supported_types: ['publish', 'fetch', 'mailbox', 'questions'] },
99
+ agent_name: '@evomap/evolver-proxy',
100
+ status: 'active',
101
+ timestamp: new Date().toISOString(),
102
+ ...(sender ? { node_id: sender } : {}),
103
+ ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
104
+ }));
105
+ const payload = asRecord(body['payload']) ?? body;
106
+ const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
107
+ const rateLimitUntilMs = numberField(payload, 'rate_limit_until_ms') ?? numberField(payload, 'rateLimitUntilMs');
108
+ // Secret-divergence recovery (v1 a2aProtocol.js L1983-2017). The hub HTTP-200'd an app-level
109
+ // rejection of our cached node_secret (status:"rejected" + a divergence reason), meaning the
110
+ // local secret has DRIFTED from the hub's record. Checked BEFORE the generic error early-return
111
+ // because the hub may carry both `error` and `status:"rejected"`. Clearing the secret (in-memory
112
+ // + durable via the auth handler) lets the next hello fall back to unauthenticated, which recovers
113
+ // cleanly; retrying with the diverged secret never can. Signals the caller NOT to arm reauth
114
+ // backoff. Only legacy node_secret auth exposes this hook — enterprise_token is a no-op.
115
+ if (isSecretDivergenceRejection(body, payload)) {
116
+ this.auth.notifyNodeSecretDiverged?.();
117
+ return {
118
+ ok: false,
119
+ error: 'secret_diverged_cleared',
120
+ secretDiverged: true,
121
+ ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
122
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
123
+ };
124
+ }
125
+ if (payload['error'])
126
+ return { ok: false, error: String(payload['error']), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}) };
127
+ const nodeId = stringField(payload, 'your_node_id')
128
+ ?? stringField(payload, 'node_id')
129
+ ?? stringField(payload, 'nodeId')
130
+ ?? stringField(payload, 'id')
131
+ ?? stringField(payload, 'senderId')
132
+ ?? this.opts.senderId();
133
+ const nodeSecret = stringField(payload, 'node_secret') ?? stringField(payload, 'nodeSecret');
134
+ const nodeSecretVersion = parseNodeSecretVersion(payload['node_secret_version'] ?? payload['nodeSecretVersion']);
135
+ if (nodeSecret && isNodeSecret(nodeSecret)) {
136
+ this.auth.adoptNodeSecret?.(nodeSecret, nodeSecretVersion);
137
+ }
138
+ else {
139
+ this.auth.adoptNodeSecretVersion?.(nodeSecretVersion);
140
+ }
141
+ return {
142
+ ok: payload['ok'] !== false && Boolean(nodeId),
143
+ ...(nodeId ? { nodeId } : {}),
144
+ ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
145
+ ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
146
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
147
+ };
148
+ }
149
+ catch (err) {
150
+ if (err instanceof AuthError)
151
+ return { ok: false, authError: true, error: `hub auth error ${err.status}`, httpStatus: err.status };
152
+ if (err instanceof HubClientError)
153
+ return helloResultFromBody(err.status, asRecord(err.body) ?? {});
154
+ if (isHubUnreachableError(err))
155
+ return { ok: false, error: 'hub_unreachable', retryAfterMs: hubUnreachableRetryAfterMs(err) };
156
+ throw err;
157
+ }
158
+ }
159
+ async heartbeat(opts = {}) {
160
+ try {
161
+ const nodeSecretVersion = this.auth.getNodeSecretVersion?.();
162
+ const meta = this.heartbeatMeta(opts, nodeSecretVersion);
163
+ const body = await this.http.call('POST', '/a2a/heartbeat', {
164
+ ...(this.opts.senderId() ? { node_id: this.opts.senderId() } : {}),
165
+ timestamp: new Date().toISOString(),
166
+ status: 'active',
167
+ ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
168
+ ...(opts.lastUpdate ? { last_update: opts.lastUpdate } : {}),
169
+ ...(nodeSecretVersion !== undefined ? { node_secret_version: nodeSecretVersion } : {}),
170
+ ...(meta ? { meta } : {}),
171
+ });
172
+ return heartbeatResultFromBody(200, body);
173
+ }
174
+ catch (err) {
175
+ if (err instanceof AuthError)
176
+ return { ok: false, authError: true, error: `hub auth error ${err.status}` };
177
+ if (err instanceof HubClientError)
178
+ return heartbeatResultFromBody(err.status, asRecord(err.body) ?? {});
179
+ if (isHubUnreachableError(err))
180
+ return { ok: false, error: 'hub_unreachable', retryAfterMs: hubUnreachableRetryAfterMs(err) };
181
+ throw err;
182
+ }
183
+ }
184
+ heartbeatMeta(opts, nodeSecretVersion) {
185
+ const meta = {};
186
+ if (nodeSecretVersion !== undefined)
187
+ meta['node_secret_version'] = nodeSecretVersion;
188
+ const antiAbuse = this.opts.antiAbuse ?? {};
189
+ if (antiAbuseTelemetryMode(antiAbuse.env) === 'heartbeat') {
190
+ try {
191
+ meta['anti_abuse'] = buildHeartbeatAntiAbuseTelemetry({
192
+ ...antiAbuse,
193
+ nodeId: this.opts.senderId(),
194
+ evolverVersion: opts.evolverVersion,
195
+ });
196
+ }
197
+ catch {
198
+ process.stderr.write('[anti-abuse] failed to build heartbeat telemetry; continuing without heartbeat meta\n');
199
+ }
200
+ }
201
+ return Object.keys(meta).length > 0 ? meta : undefined;
202
+ }
203
+ async publish(bundle) {
204
+ try {
205
+ // 公版 /a2a/publish 收 payload.assets=[Gene,Capsule,(Event)] 捆绑(实测 dev).
206
+ const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }));
207
+ return publishRespToReceipt(200, body);
208
+ }
209
+ catch (e) {
210
+ if (e instanceof HubClientError)
211
+ return publishRespToReceipt(e.status, e.body ?? {});
212
+ throw e; // 5xx/网络 → 重试
213
+ }
214
+ }
215
+ async fetch(query) {
216
+ // #69: map camelCase SearchQuery → hub snake_case wire (signalsAny → signals) before sending.
217
+ // /a2a/fetch responses are FULL GEP envelopes (buildResponse('fetch', …)); the rows live at payload.results,
218
+ // NOT at the top level. Reading body.results here always yielded [] — every fetch silently returned nothing.
219
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToFetchWire(query)));
220
+ return (body.payload?.results ?? []);
221
+ }
222
+ async fetchAssetById(assetId) {
223
+ const id = assetId.trim();
224
+ if (!id)
225
+ return null;
226
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
227
+ return assetsFromBody(body).find((asset) => assetMatchesId(asset, id)) ?? null;
228
+ }
229
+ /**
230
+ * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
231
+ * signals/id queries fall through to fetch. /a2a/fetch does NOT do semantic, so text must not go there.
232
+ */
233
+ async search(query) {
234
+ if (query.text && query.text.trim()) {
235
+ // GET /a2a/assets/semantic-search returns a FLAT object keyed `assets` (no GEP envelope), plus a
236
+ // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Reading body.results
237
+ // here always yielded [] — the semantic path could never return a hit. (Status not surfaced yet: the
238
+ // HubCapability.search contract is AssetRecord[]; honoring search_status needs an interface change — later.)
239
+ const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, { q: query.text, ...(query.limit !== undefined ? { limit: query.limit } : {}) });
240
+ return (body.assets ?? []);
241
+ }
242
+ return this.fetch(query);
243
+ }
244
+ /**
245
+ * Report a cycle outcome to the hub's memory graph (POST /a2a/memory/record).
246
+ * Unlike the protocol-message endpoints (publish/fetch), memory/record takes a
247
+ * FLAT body — no GEP envelope. Reporting is observability for the network's
248
+ * attribution loop, never a dependency of the cycle itself, so this method
249
+ * NEVER throws: auth/4xx/5xx/network failures all degrade to `recorded:false`.
250
+ * Costs hub credits per the hub's memory pricing (caller gates on enablement).
251
+ */
252
+ async recordOutcome(report) {
253
+ const signals = report.signals.map((s) => String(s).trim()).filter(Boolean);
254
+ if (signals.length === 0)
255
+ return { recorded: false, reason: 'no_signals' }; // hub rejects empty signals; skip the paid call
256
+ const usedAssetIds = [...new Set((report.usedAssetIds ?? []).filter((x) => typeof x === 'string' && x.length > 0 && x.length <= USED_ASSET_ID_MAX_LEN))].slice(0, USED_ASSET_IDS_MAX);
257
+ try {
258
+ await this.http.call('POST', '/a2a/memory/record', {
259
+ signals,
260
+ status: report.status,
261
+ ...(report.geneId ? { gene_id: report.geneId } : {}),
262
+ ...(report.score !== undefined ? { score: report.score } : {}),
263
+ ...(report.summary ? { summary: report.summary } : {}),
264
+ ...(usedAssetIds.length > 0 ? { used_asset_ids: usedAssetIds } : {}),
265
+ });
266
+ return { recorded: true };
267
+ }
268
+ catch (e) {
269
+ return { recorded: false, reason: e instanceof Error ? e.message : String(e) };
270
+ }
271
+ }
272
+ async recordReuseResult(report) {
273
+ const assetId = report.assetId.trim();
274
+ if (!assetId)
275
+ return { recorded: false, reason: 'asset_id_required' };
276
+ const tokensSaved = optionalReuseMetric(report, 'tokensSaved', 'tokens_saved', 'invalid_tokens_saved');
277
+ if ('reason' in tokensSaved)
278
+ return { recorded: false, reason: tokensSaved.reason };
279
+ const timeSavedSeconds = optionalReuseMetric(report, 'timeSavedSeconds', 'time_saved_seconds', 'invalid_time_saved_seconds');
280
+ if ('reason' in timeSavedSeconds)
281
+ return { recorded: false, reason: timeSavedSeconds.reason };
282
+ try {
283
+ const body = await this.http.call('POST', `/a2a/assets/${encodeURIComponent(assetId)}/reuse-result`, {
284
+ asset_id: assetId,
285
+ outcome: report.outcome,
286
+ ...(report.taskId ? { task_id: report.taskId } : {}),
287
+ ...(report.traceId ? { trace_id: report.traceId } : {}),
288
+ ...(timeSavedSeconds.value !== undefined ? { time_saved_seconds: timeSavedSeconds.value } : {}),
289
+ ...(report.reason ? { reason: report.reason } : {}),
290
+ });
291
+ const payload = asRecord(body['payload']) ?? body;
292
+ return {
293
+ recorded: payload['recorded'] !== false && payload['ok'] !== false,
294
+ ...(stringField(payload, 'reason') ?? stringField(payload, 'error') ? { reason: stringField(payload, 'reason') ?? stringField(payload, 'error') } : {}),
295
+ ...(stringField(payload, 'id') ?? stringField(payload, 'receipt_id') ? { id: stringField(payload, 'id') ?? stringField(payload, 'receipt_id') } : {}),
296
+ };
297
+ }
298
+ catch (e) {
299
+ return { recorded: false, reason: e instanceof Error ? e.message : String(e) };
300
+ }
301
+ }
302
+ /**
303
+ * Pre-publish dry-run (POST /a2a/validate). The hub runs the same hub-side quality +
304
+ * content-safety gate as publish but stores nothing and charges no credits. This adapter is
305
+ * the raw HubCapability; proxy-facing callers sanitize/leak-check before invoking it so the
306
+ * public tool matches publish's local egress guard. Like publish, the payload is the
307
+ * {assets:[…]} bundle wrapped in a FULL GEP-A2A envelope — /a2a/validate is a strict protocol endpoint
308
+ * (validateProtocol(["validate","publish"])) and 400s on a bare body. A dry-run is never
309
+ * a dependency of the cycle, so this NEVER throws: quality reject (400) / content-safety
310
+ * reject (422) / 5xx / network all degrade to { valid:false, reason }.
311
+ */
312
+ async validate(bundle) {
313
+ try {
314
+ const body = await this.http.call('POST', '/a2a/validate', gepEnvelope('validate', { assets: bundle }));
315
+ // Success is a GEP envelope: buildResponse('decision', { valid, reason, … }) — read payload.valid.
316
+ const payload = asRecord(body['payload']) ?? body;
317
+ const reason = stringField(payload, 'reason') ?? stringField(payload, 'error');
318
+ return {
319
+ valid: payload['valid'] !== false && payload['ok'] !== false,
320
+ ...(reason ? { reason } : {}),
321
+ raw: payload,
322
+ };
323
+ }
324
+ catch (e) {
325
+ // 400 quality_reject / 422 content_safety_rejected come back as HubClientError with the hub's JSON body.
326
+ if (e instanceof HubClientError) {
327
+ const errBody = asRecord(e.body) ?? {};
328
+ const payload = asRecord(errBody['payload']) ?? errBody;
329
+ const reason = stringField(payload, 'reason') ?? stringField(payload, 'error') ?? `hub ${e.status}`;
330
+ return { valid: false, reason, raw: payload };
331
+ }
332
+ return { valid: false, reason: e instanceof Error ? e.message : String(e) };
333
+ }
334
+ }
335
+ async createRecipe(request) {
336
+ if (isHubDryRunEnabled()) {
337
+ return dryRunRecipeReceipt('create_recipe', DRY_RUN_RECIPE_ID, {
338
+ request: {
339
+ title: request.title,
340
+ steps: request.steps.map(recipeStepToWire),
341
+ ...(request.description ? { description: request.description } : {}),
342
+ ...(request.pricePerExecution !== undefined ? { price_per_execution: request.pricePerExecution } : {}),
343
+ ...(request.currency ? { currency: request.currency } : {}),
344
+ ...(request.maxConcurrent !== undefined ? { max_concurrent: request.maxConcurrent } : {}),
345
+ },
346
+ });
347
+ }
348
+ const sender = this.opts.senderId();
349
+ const body = await this.http.call('POST', '/a2a/recipe', {
350
+ ...(sender ? { node_id: sender } : {}),
351
+ title: request.title,
352
+ steps: request.steps.map(recipeStepToWire),
353
+ ...(request.description ? { description: request.description } : {}),
354
+ ...(request.pricePerExecution !== undefined ? { price_per_execution: request.pricePerExecution } : {}),
355
+ ...(request.currency ? { currency: request.currency } : {}),
356
+ ...(request.maxConcurrent !== undefined ? { max_concurrent: request.maxConcurrent } : {}),
357
+ });
358
+ return recipeReceiptFromBody(body);
359
+ }
360
+ async publishRecipe(recipeId) {
361
+ if (isHubDryRunEnabled())
362
+ return dryRunRecipeReceipt('publish_recipe', recipeId);
363
+ const sender = this.opts.senderId();
364
+ const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/publish`, { ...(sender ? { node_id: sender } : {}) });
365
+ return recipeReceiptFromBody(body);
366
+ }
367
+ async getRecipe(recipeId) {
368
+ if (isHubDryRunEnabled()) {
369
+ return {
370
+ ...dryRunRecipeReceipt('get_recipe', recipeId),
371
+ recipe: { id: recipeId, dry_run: true },
372
+ };
373
+ }
374
+ const body = await this.http.call('GET', `/a2a/recipe/${encodeURIComponent(recipeId)}`);
375
+ const recipe = recipeFromBody(body);
376
+ return {
377
+ ...recipeReceiptFromBody(body),
378
+ ...(recipe !== undefined ? { recipe } : {}),
379
+ };
380
+ }
381
+ async expressRecipe(recipeId, request = {}) {
382
+ if (isHubDryRunEnabled()) {
383
+ return dryRunRecipeReceipt('express_recipe', recipeId, { input_payload: request.inputPayload ?? {} });
384
+ }
385
+ const sender = this.opts.senderId();
386
+ const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/express`, {
387
+ ...(sender ? { node_id: sender } : {}),
388
+ input_payload: request.inputPayload ?? {},
389
+ });
390
+ const payload = recipePayload(body);
391
+ const organismId = recipeOrganismIdFromPayload(payload);
392
+ const receipt = recipeReceiptFromBody(body);
393
+ return {
394
+ ...receipt,
395
+ recipeId: receipt.recipeId ?? recipeId,
396
+ ...(organismId ? { organismId } : {}),
397
+ };
398
+ }
399
+ task = {
400
+ claim: async (taskId) => {
401
+ await this.http.call('POST', '/a2a/mailbox/outbound', { messages: [{ id: `claim-${taskId}`, type: 'task_claim', payload: { taskId } }] });
402
+ return { claimId: `claim-${taskId}` };
403
+ },
404
+ complete: async (claimId, result) => {
405
+ await this.http.call('POST', '/a2a/mailbox/outbound', { messages: [{ id: `complete-${claimId}`, type: 'task_complete', payload: { claimId, result } }] });
406
+ return { status: 'completed' };
407
+ },
408
+ subscribe: (filter) => this.subscribeTasks(filter),
409
+ };
410
+ questions = {
411
+ submit: async (questions) => this.submitQuestions(questions),
412
+ };
413
+ async submitQuestions(questions) {
414
+ const payloadQuestions = questions
415
+ .map(normalizeQuestion)
416
+ .filter((q) => q !== null);
417
+ if (payloadQuestions.length === 0)
418
+ return [];
419
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', {
420
+ tasks_only: true,
421
+ include_tasks: true,
422
+ // Current Hub creates payload.questions only after the tasks_only fast path.
423
+ // A nonexistent direct asset lookup bypasses that path while still returning
424
+ // no fetch rows or reuse credit.
425
+ content_hash: QUESTION_SUBMIT_FAST_PATH_BYPASS_CONTENT_HASH,
426
+ questions: payloadQuestions,
427
+ }));
428
+ return questionReceiptsFromBody(body);
429
+ }
430
+ async *subscribeTasks(_filter) {
431
+ // 公版只有 /a2a/events/poll(短/长轮询). 单次拉取转 TaskEvent; 节奏由调用方驱动.
432
+ const body = await this.http.call('POST', '/a2a/events/poll', gepEnvelope('events_poll', { timeout_ms: 1000 }));
433
+ for (const e of body.events ?? []) {
434
+ if (String(e['type']).startsWith('task_')) {
435
+ yield { taskId: String(e['payload']?.taskId ?? e['id']), type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
436
+ }
437
+ }
438
+ }
439
+ mailbox = {
440
+ poll: async () => {
441
+ const body = await this.http.call('POST', '/a2a/mailbox/inbound', { limit: INBOUND_LIMIT });
442
+ const events = (body.messages ?? []).map((m) => ({ ...inboundToAgentEvent(m), ...(body.next_cursor ? { cursor: body.next_cursor } : {}) }));
443
+ return {
444
+ events,
445
+ ...(body.next_poll_after_ms !== undefined ? { nextPollAfterMs: body.next_poll_after_ms } : {}), // #1195 选读
446
+ hasMore: body.has_more ?? false,
447
+ };
448
+ },
449
+ ack: async (eventId) => { await this.http.call('POST', '/a2a/mailbox/ack', { message_ids: [eventId] }); },
450
+ push: async (event) => {
451
+ const result = await this.mailbox.pushMany([event]);
452
+ const outcome = result?.outcomes.find((item) => item.id === event.id);
453
+ if (outcome?.status !== 'failed')
454
+ return;
455
+ throw new hubNs.PublishRejectedError('mailbox_push_rejected', outcome.terminal ?? outcome.retryable !== true, outcome.reason ?? 'mailbox_push_rejected', outcome.retryAfterMs, outcome.retryable);
456
+ },
457
+ pushMany: async (events) => {
458
+ if (events.length === 0)
459
+ return { outcomes: [] };
460
+ const outcomes = [];
461
+ for (const batch of splitMailboxOutboundBatches(events)) {
462
+ if (batch.tooLarge) {
463
+ outcomes.push({
464
+ id: batch.event.id,
465
+ status: 'failed',
466
+ reason: 'mailbox_payload_too_large',
467
+ terminal: true,
468
+ });
469
+ continue;
470
+ }
471
+ try {
472
+ const body = await this.http.call('POST', '/a2a/mailbox/outbound', { messages: batch.events.map(agentEventToOutbound) });
473
+ outcomes.push(...mailboxPushResultFromBody(body, batch.events).outcomes);
474
+ }
475
+ catch (err) {
476
+ if (hubErrorStatus(err) === 413) {
477
+ if (batch.events.length === 1) {
478
+ outcomes.push({
479
+ id: batch.events[0].id,
480
+ status: 'failed',
481
+ reason: 'mailbox_payload_too_large',
482
+ terminal: true,
483
+ });
484
+ continue;
485
+ }
486
+ for (const single of batch.events) {
487
+ try {
488
+ const body = await this.http.call('POST', '/a2a/mailbox/outbound', { messages: [agentEventToOutbound(single)] });
489
+ outcomes.push(...mailboxPushResultFromBody(body, [single]).outcomes);
490
+ }
491
+ catch (singleErr) {
492
+ if (hubErrorStatus(singleErr) === 413) {
493
+ outcomes.push({
494
+ id: single.id,
495
+ status: 'failed',
496
+ reason: 'mailbox_payload_too_large',
497
+ terminal: true,
498
+ });
499
+ continue;
500
+ }
501
+ if (outcomes.length === 0 && hubErrorStatus(singleErr) !== 429)
502
+ throw singleErr;
503
+ outcomes.push(...mailboxPushFailureOutcomes(events, outcomes, singleErr, new Set([single.id])));
504
+ return { outcomes };
505
+ }
506
+ }
507
+ continue;
508
+ }
509
+ if (outcomes.length === 0 && hubErrorStatus(err) !== 429)
510
+ throw err;
511
+ outcomes.push(...mailboxPushFailureOutcomes(events, outcomes, err, new Set(batch.events.map((e) => e.id))));
512
+ return { outcomes };
513
+ }
514
+ }
515
+ return { outcomes };
516
+ },
517
+ status: async () => {
518
+ const body = await this.http.call('GET', '/a2a/mailbox/status');
519
+ return { pending: body.pending ?? 0 };
520
+ },
521
+ };
522
+ async capabilities() {
523
+ return {
524
+ capabilities: PUBLIC_HUB_CAPABILITIES,
525
+ protocolVersion: PUBLIC_PROTOCOL_VERSION,
526
+ economyEnabled: true,
527
+ authKinds: ['oauth_device_token', 'keypair'],
528
+ auditEnabled: false,
529
+ airGap: false,
530
+ tenantIsolation: false,
531
+ marketplaceAccess: true,
532
+ };
533
+ }
534
+ }
535
+ function asRecord(value) {
536
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
537
+ }
538
+ function recipeStepToWire(step) {
539
+ return {
540
+ asset_id: step.assetId,
541
+ asset_type: step.assetType,
542
+ ...(step.position !== undefined ? { position: step.position } : {}),
543
+ };
544
+ }
545
+ function recipePayload(body) {
546
+ return asRecord(body['payload']) ?? body;
547
+ }
548
+ function recipeFromBody(body) {
549
+ const payload = recipePayload(body);
550
+ if (payload['recipe'] !== undefined)
551
+ return payload['recipe'];
552
+ return isRecipeLikeRecord(payload) ? payload : undefined;
553
+ }
554
+ function isRecipeLikeRecord(value) {
555
+ return Boolean(stringField(value, 'id')
556
+ ?? stringField(value, 'recipe_id')
557
+ ?? stringField(value, 'recipeId'));
558
+ }
559
+ function recipeOrganismIdFromPayload(payload) {
560
+ const flatId = stringField(payload, 'organism_id') ?? stringField(payload, 'organismId');
561
+ if (flatId)
562
+ return flatId;
563
+ const organism = asRecord(payload['organism']);
564
+ return organism
565
+ ? stringField(organism, 'id') ?? stringField(organism, 'organism_id') ?? stringField(organism, 'organismId')
566
+ : undefined;
567
+ }
568
+ function recipeReceiptFromBody(body) {
569
+ const payload = recipePayload(body);
570
+ const recipe = asRecord(payload['recipe']);
571
+ const source = recipe ?? payload;
572
+ return {
573
+ ...(stringField(source, 'id') ?? stringField(source, 'recipe_id') ?? stringField(source, 'recipeId')
574
+ ? { recipeId: stringField(source, 'id') ?? stringField(source, 'recipe_id') ?? stringField(source, 'recipeId') }
575
+ : {}),
576
+ ...(stringField(source, 'status') ? { status: stringField(source, 'status') } : {}),
577
+ raw: body,
578
+ };
579
+ }
580
+ function dryRunRecipeReceipt(action, recipeId, extra = {}) {
581
+ return {
582
+ recipeId,
583
+ status: 'dry-run',
584
+ raw: {
585
+ dry_run: true,
586
+ would: action,
587
+ recipe_id: recipeId,
588
+ ...extra,
589
+ },
590
+ };
591
+ }
592
+ function assetsFromBody(body) {
593
+ const payload = asRecord(body['payload']);
594
+ const candidates = [
595
+ body['asset'],
596
+ payload?.['asset'],
597
+ ...(Array.isArray(body['assets']) ? body['assets'] : []),
598
+ ...(Array.isArray(body['results']) ? body['results'] : []),
599
+ ...(Array.isArray(payload?.['assets']) ? payload['assets'] : []),
600
+ ...(Array.isArray(payload?.['results']) ? payload['results'] : []),
601
+ ];
602
+ return candidates.filter((candidate) => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate)));
603
+ }
604
+ function assetMatchesId(asset, assetId) {
605
+ return Boolean(asset && (asset.asset_id === assetId || stringField(asset, 'id') === assetId));
606
+ }
607
+ function stringField(value, key) {
608
+ return typeof value[key] === 'string' && value[key].length > 0 ? value[key] : undefined;
609
+ }
610
+ function numberField(value, key) {
611
+ const raw = value[key];
612
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? Number(raw) : NaN);
613
+ return Number.isFinite(n) ? n : undefined;
614
+ }
615
+ function booleanField(value, key) {
616
+ const raw = value[key];
617
+ if (typeof raw === 'boolean')
618
+ return raw;
619
+ if (raw === 'true' || raw === '1')
620
+ return true;
621
+ if (raw === 'false' || raw === '0')
622
+ return false;
623
+ return undefined;
624
+ }
625
+ function mailboxResultRows(body) {
626
+ const payload = asRecord(body['payload']);
627
+ const candidates = [body['results'], body['messages'], payload?.['results'], payload?.['messages']];
628
+ for (const candidate of candidates) {
629
+ if (!Array.isArray(candidate))
630
+ continue;
631
+ return candidate.filter((item) => Boolean(asRecord(item)));
632
+ }
633
+ return [];
634
+ }
635
+ function mailboxOutboundBodyBytes(events) {
636
+ return Buffer.byteLength(JSON.stringify({ messages: events.map(agentEventToOutbound) }), 'utf8');
637
+ }
638
+ function eventOutboundMaxBodyBytes(event) {
639
+ return event.type === 'proxy_trace' ? traceOutboundMaxBodyBytes() : outboundMaxBodyBytes();
640
+ }
641
+ function batchOutboundMaxBodyBytes(events) {
642
+ return Math.min(...events.map(eventOutboundMaxBodyBytes));
643
+ }
644
+ function splitMailboxOutboundBatches(events) {
645
+ const out = [];
646
+ let batch = [];
647
+ for (const event of events) {
648
+ if (mailboxOutboundBodyBytes([event]) > eventOutboundMaxBodyBytes(event)) {
649
+ if (batch.length > 0) {
650
+ out.push({ events: batch });
651
+ batch = [];
652
+ }
653
+ out.push({ event, tooLarge: true });
654
+ continue;
655
+ }
656
+ const next = [...batch, event];
657
+ if (batch.length > 0 && (batch.length >= OUTBOUND_MAX_BATCH || mailboxOutboundBodyBytes(next) > batchOutboundMaxBodyBytes(next))) {
658
+ out.push({ events: batch });
659
+ batch = [event];
660
+ }
661
+ else {
662
+ batch = next;
663
+ }
664
+ }
665
+ if (batch.length > 0)
666
+ out.push({ events: batch });
667
+ return out;
668
+ }
669
+ function mailboxPushResultFromBody(body, events) {
670
+ const results = mailboxResultRows(body);
671
+ if (results.length === 0) {
672
+ return { outcomes: events.map((event) => ({ id: event.id, status: 'accepted' })) };
673
+ }
674
+ return {
675
+ outcomes: events.map((event, index) => mailboxPushOutcomeFromRow(event.id, results, index)),
676
+ };
677
+ }
678
+ function mailboxPushOutcomeFromRow(eventId, results, index) {
679
+ const match = results.find((result) => String(result['id'] ?? result['message_id'] ?? '') === eventId) ?? results[index];
680
+ if (!match)
681
+ return { id: eventId, status: 'accepted' };
682
+ const reason = mailboxPushFailureReason(match);
683
+ if (!reason)
684
+ return { id: eventId, status: 'accepted', raw: match };
685
+ const retryAfterMs = numberField(match, 'retryAfterMs')
686
+ ?? numberField(match, 'retry_after_ms')
687
+ ?? (() => {
688
+ const retryAfterSeconds = numberField(match, 'retry_after') ?? numberField(match, 'retryAfter');
689
+ return retryAfterSeconds !== undefined ? retryAfterSeconds * 1000 : undefined;
690
+ })();
691
+ const retryable = booleanField(match, 'retryable');
692
+ const terminal = booleanField(match, 'terminal');
693
+ return {
694
+ id: eventId,
695
+ status: 'failed',
696
+ reason,
697
+ ...(retryable !== undefined ? { retryable } : {}),
698
+ ...(terminal !== undefined ? { terminal } : {}),
699
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
700
+ raw: match,
701
+ };
702
+ }
703
+ // A 413 (payload too large) or 429 (rate-limited) can surface either as a JSON
704
+ // HubClientError, OR — when an ingress/gateway (Envoy/nginx) emits a non-JSON
705
+ // body — as a HubUnreachableError carrying the HTTP status in `details.status`
706
+ // (hubFetch classifies non-API responses as unreachable BEFORE the 4xx branch).
707
+ // Read the status from both shapes so a gateway 413/429 isn't misrouted into the
708
+ // "defer forever" path.
709
+ function hubErrorStatus(err) {
710
+ if (err instanceof HubClientError)
711
+ return err.status;
712
+ if (isHubUnreachableError(err)) {
713
+ const status = err.details?.status;
714
+ return typeof status === 'number' ? status : undefined;
715
+ }
716
+ return undefined;
717
+ }
718
+ // Retry-After hint from a HubClientError JSON body (429 cooldown). Headers aren't
719
+ // carried on HubClientError, so we read the body's retry_after_ms / retry_after.
720
+ function clientErrorRetryAfterMs(err) {
721
+ const body = err instanceof HubClientError ? asRecord(err.body) : undefined;
722
+ if (!body)
723
+ return undefined;
724
+ const ms = numberField(body, 'retry_after_ms') ?? numberField(body, 'retryAfterMs');
725
+ if (ms !== undefined)
726
+ return ms;
727
+ const sec = numberField(body, 'retry_after') ?? numberField(body, 'retryAfter');
728
+ return sec !== undefined ? sec * 1000 : undefined;
729
+ }
730
+ function mailboxPushFailureOutcomes(events, outcomes, err, attemptedIds) {
731
+ const completed = new Set(outcomes.map((outcome) => outcome.id));
732
+ const reason = err instanceof Error ? err.message : String(err);
733
+ // 429 → defer with the hub's Retry-After (no attempt burn). Hub-unreachable →
734
+ // existing backoff. Everything else → plain failed (normal retry).
735
+ const cooldownMs = hubErrorStatus(err) === 429
736
+ ? Math.max(1_000, clientErrorRetryAfterMs(err) ?? 60_000)
737
+ : isHubUnreachableError(err) ? hubUnreachableRetryAfterMs(err) : undefined;
738
+ return events
739
+ .filter((event) => !completed.has(event.id))
740
+ .map((event) => {
741
+ // Messages past the failing batch were never put on the wire — defer them
742
+ // (no attempt burn) rather than charging a failed attempt for a send that
743
+ // never happened. The batch that actually failed keeps normal failed/backoff
744
+ // semantics (or the cooldown defer above for 429 / hub-unreachable).
745
+ const attempted = attemptedIds === undefined || attemptedIds.has(event.id);
746
+ const retryAfterMs = cooldownMs ?? (attempted ? undefined : 1_000);
747
+ return {
748
+ id: event.id,
749
+ status: 'failed',
750
+ reason,
751
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
752
+ };
753
+ });
754
+ }
755
+ // Mailbox push success/failure is decided SOLELY by the per-message TOP-LEVEL
756
+ // `status`, mirroring v1 (src/proxy/sync/outbound.js:127, which only reads
757
+ // `r.status`). The hub's processOutbound (src/services/mailboxService.js)
758
+ // returns exactly three top-level statuses — `ok` (deduplicated), `accepted`
759
+ // (dispatched), `failed` (dispatch threw) — and NEVER puts `ok`/`accepted` as
760
+ // top-level fields. Critically, a proxy_trace whose node has trace collection
761
+ // disabled still comes back top-level `status:"accepted"` with a nested
762
+ // `response:{accepted:false,...}` (it just isn't deduped, mailboxService.js:31-33);
763
+ // that is NOT a transport failure. So we must judge ONLY `status` here: treating
764
+ // `response.accepted===false` as failure (as the old code did) made store.fail
765
+ // back off and resend forever, since the hub keeps returning accepted:false.
766
+ function mailboxPushFailureReason(row) {
767
+ const status = stringField(row, 'status')?.toLowerCase();
768
+ const error = stringField(row, 'error') ?? stringField(row, 'reason');
769
+ if (status
770
+ && (status.includes('fail')
771
+ || status.includes('reject')
772
+ || status.includes('error')
773
+ || status.includes('invalid')
774
+ || status === 'quarantine')) {
775
+ return error ?? status;
776
+ }
777
+ return undefined;
778
+ }
779
+ function optionalReuseMetric(report, camelKey, snakeKey, reason) {
780
+ const rawReport = report;
781
+ let value;
782
+ for (const key of [camelKey, snakeKey]) {
783
+ if (!Object.prototype.hasOwnProperty.call(rawReport, key))
784
+ continue;
785
+ const raw = rawReport[key];
786
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)
787
+ return { reason };
788
+ value ??= raw;
789
+ }
790
+ return { value };
791
+ }
792
+ function normalizeQuestion(question) {
793
+ const text = String(question.question ?? '').trim();
794
+ if (!text)
795
+ return null;
796
+ const signals = Array.isArray(question.signals)
797
+ ? question.signals.map((s) => String(s).trim()).filter(Boolean)
798
+ : [];
799
+ return {
800
+ question: text,
801
+ ...(question.amount !== undefined ? { amount: question.amount } : {}),
802
+ ...(signals.length > 0 ? { signals } : {}),
803
+ };
804
+ }
805
+ function questionReceiptsFromBody(body) {
806
+ const payload = asRecord(body['payload']) ?? body;
807
+ const rows = payload['questions_created'] ?? payload['questionsCreated'] ?? payload['questions'];
808
+ if (!Array.isArray(rows))
809
+ return [];
810
+ return rows.map(questionReceiptFromRow);
811
+ }
812
+ function questionReceiptFromRow(row) {
813
+ const rec = asRecord(row);
814
+ if (!rec)
815
+ return { raw: row };
816
+ const question = stringField(rec, 'question') ?? stringField(rec, 'title');
817
+ const taskId = stringField(rec, 'task_id') ?? stringField(rec, 'taskId') ?? stringField(rec, 'id');
818
+ const bountyId = stringField(rec, 'bounty_id') ?? stringField(rec, 'bountyId');
819
+ const error = stringField(rec, 'error') ?? stringField(rec, 'reason');
820
+ return {
821
+ ...(question ? { question } : {}),
822
+ ...(taskId ? { taskId } : {}),
823
+ ...(bountyId ? { bountyId } : {}),
824
+ ...(error ? { error } : {}),
825
+ raw: row,
826
+ };
827
+ }
828
+ function nonNegativeFiniteNumberField(value, key) {
829
+ const raw = value[key];
830
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? raw : undefined;
831
+ }
832
+ function helloResultFromBody(httpStatus, body) {
833
+ const payload = asRecord(body['payload']) ?? body;
834
+ const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
835
+ const rateLimitUntilMs = numberField(payload, 'rate_limit_until_ms') ?? numberField(payload, 'rateLimitUntilMs');
836
+ const status = stringField(payload, 'status');
837
+ const error = stringField(payload, 'error') ?? stringField(payload, 'reason') ?? (httpStatus >= 400 ? `http_${httpStatus}` : undefined);
838
+ const details = payload['details'] ?? body['details'];
839
+ const authError = httpStatus === 401 || httpStatus === 403 || status === 'auth_failed' || status === 'invalid_secret';
840
+ return {
841
+ ok: false,
842
+ ...(authError ? { authError: true } : {}),
843
+ ...(httpStatus >= 400 ? { httpStatus } : {}),
844
+ ...(error ? { error } : {}),
845
+ ...(details !== undefined ? { details } : {}),
846
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
847
+ ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
848
+ ...(status ? { status } : {}),
849
+ };
850
+ }
851
+ function hubUnreachableRetryAfterMs(err) {
852
+ const retryAfterMs = err?.retryAfterMs
853
+ ?? err?.details?.retryAfterMs;
854
+ return typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) ? retryAfterMs : 60_000;
855
+ }
856
+ function heartbeatResultFromBody(httpStatus, body) {
857
+ const payload = asRecord(body['payload']) ?? body;
858
+ const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
859
+ const status = stringField(payload, 'status');
860
+ const error = stringField(payload, 'error') ?? (httpStatus >= 400 ? `http_${httpStatus}` : undefined);
861
+ const details = payload['details'] ?? body['details'];
862
+ const ack = asRecord(payload['last_update_ack']);
863
+ const forceUpdate = forceUpdateFromRecord(asRecord(payload['force_update']));
864
+ const ok = httpStatus >= 200
865
+ && httpStatus < 300
866
+ && payload['ok'] !== false
867
+ && status !== 'unknown_node'
868
+ && !error;
869
+ return {
870
+ ok,
871
+ ...(httpStatus >= 400 ? { httpStatus } : {}),
872
+ ...(error ? { error } : {}),
873
+ ...(details !== undefined ? { details } : {}),
874
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
875
+ ...(status ? { status } : {}),
876
+ ...(ack ? { lastUpdateAck: {
877
+ ...(typeof ack['ok'] === 'boolean' ? { ok: ack['ok'] } : {}),
878
+ ...(typeof ack['reason'] === 'string' ? { reason: ack['reason'] } : {}),
879
+ } } : {}),
880
+ ...(forceUpdate ? { forceUpdate } : {}),
881
+ };
882
+ }
883
+ function forceUpdateFromRecord(value) {
884
+ if (!value)
885
+ return undefined;
886
+ const deadlineMs = nonNegativeFiniteNumberField(value, 'deadline_ms');
887
+ const staggerWindowMs = nonNegativeFiniteNumberField(value, 'stagger_window_ms');
888
+ const directive = {
889
+ ...(typeof value['required_version'] === 'string' ? { required_version: value['required_version'] } : {}),
890
+ ...('manifest' in value ? { manifest: value['manifest'] } : {}),
891
+ ...(typeof value['reason'] === 'string' ? { reason: value['reason'] } : {}),
892
+ ...(typeof value['release_url'] === 'string' ? { release_url: value['release_url'] } : {}),
893
+ ...(Array.isArray(value['update_channels']) ? { update_channels: value['update_channels'].filter((x) => typeof x === 'string') } : {}),
894
+ ...(typeof value['directive_id'] === 'string' ? { directive_id: value['directive_id'] } : {}),
895
+ ...(deadlineMs !== undefined ? { deadline_ms: deadlineMs } : {}),
896
+ ...(staggerWindowMs !== undefined ? { stagger_window_ms: staggerWindowMs } : {}),
897
+ };
898
+ return directive.required_version || directive.manifest !== undefined ? directive : undefined;
899
+ }