@superjackfruitlabs/salaam 0.0.1 → 0.0.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +25 -17
  3. package/dist/src/a2a-backend.js +225 -0
  4. package/dist/src/a2a-inbound.js +487 -0
  5. package/dist/src/a2a-peer.js +370 -0
  6. package/dist/src/a2a-protocol.js +250 -0
  7. package/dist/src/agent-profiles.js +289 -0
  8. package/dist/src/children.js +132 -26
  9. package/dist/src/claude-provider.js +2 -179
  10. package/dist/src/cli-help.js +107 -6
  11. package/dist/src/cli.js +477 -13
  12. package/dist/src/config.js +26 -3
  13. package/dist/src/credentials.js +7 -2
  14. package/dist/src/external-runtime.js +1 -0
  15. package/dist/src/external-work.js +583 -0
  16. package/dist/src/harness-backend.js +466 -0
  17. package/dist/src/harness-container.js +450 -0
  18. package/dist/src/harness-model-gateway.js +104 -0
  19. package/dist/src/harness-prepare.js +88 -0
  20. package/dist/src/harness-probe.js +111 -0
  21. package/dist/src/harness-protocol.js +325 -0
  22. package/dist/src/harness-readiness.js +50 -0
  23. package/dist/src/harness-rpc.js +206 -0
  24. package/dist/src/harness-tool-identity.js +87 -0
  25. package/dist/src/harness-wire-bridge.js +387 -0
  26. package/dist/src/host.js +6 -1
  27. package/dist/src/house-extension.js +147 -64
  28. package/dist/src/live-provider.js +301 -0
  29. package/dist/src/managed.js +33 -3
  30. package/dist/src/protocol.js +9 -5
  31. package/dist/src/provider-auth.js +300 -0
  32. package/dist/src/provider-config.js +65 -17
  33. package/dist/src/provider-model.js +78 -0
  34. package/dist/src/provider-payload.js +264 -0
  35. package/dist/src/provider-transport.js +36 -0
  36. package/dist/src/provider-usage.js +59 -0
  37. package/dist/src/repository-snapshots.js +219 -0
  38. package/dist/src/service.js +274 -9
  39. package/dist/src/store.js +44 -2
  40. package/dist/src/supervisor.js +246 -35
  41. package/dist/src/task-budget.js +17 -8
  42. package/dist/src/task-context.js +15 -0
  43. package/dist/src/task-driver.js +38 -14
  44. package/dist/src/task-policy.js +20 -1
  45. package/dist/src/task-verification.js +3 -0
  46. package/dist/src/task-work.js +31 -0
  47. package/dist/src/team-work.js +378 -0
  48. package/dist/src/tui-view.js +65 -10
  49. package/dist/src/tui.js +4 -2
  50. package/dist/src/usage.js +18 -0
  51. package/dist/src/worker-boundary.js +298 -0
  52. package/dist/src/worker-workspaces.js +274 -0
  53. package/dist/src/workspaces.js +6 -2
  54. package/harness-runtime/codex-pins.json +10 -0
  55. package/harness-runtime/package-lock.json +560 -0
  56. package/harness-runtime/package.json +9 -0
  57. package/npm-shrinkwrap.json +57 -2
  58. package/package.json +2 -1
  59. package/src/a2a-backend.ts +254 -0
  60. package/src/a2a-inbound.ts +585 -0
  61. package/src/a2a-peer.ts +441 -0
  62. package/src/a2a-protocol.ts +286 -0
  63. package/src/agent-profiles.ts +379 -0
  64. package/src/children.ts +168 -27
  65. package/src/claude-provider.ts +2 -189
  66. package/src/cli-help.ts +107 -6
  67. package/src/cli.ts +602 -12
  68. package/src/config.ts +34 -3
  69. package/src/credentials.ts +9 -2
  70. package/src/external-runtime.ts +54 -0
  71. package/src/external-work.ts +663 -0
  72. package/src/harness-backend.ts +557 -0
  73. package/src/harness-container.ts +543 -0
  74. package/src/harness-model-gateway.ts +138 -0
  75. package/src/harness-prepare.ts +113 -0
  76. package/src/harness-probe.ts +150 -0
  77. package/src/harness-protocol.ts +388 -0
  78. package/src/harness-readiness.ts +71 -0
  79. package/src/harness-rpc.ts +244 -0
  80. package/src/harness-tool-identity.ts +91 -0
  81. package/src/harness-wire-bridge.ts +418 -0
  82. package/src/host.ts +11 -6
  83. package/src/house-extension.ts +165 -66
  84. package/src/live-provider.ts +347 -0
  85. package/src/managed.ts +43 -4
  86. package/src/protocol.ts +36 -8
  87. package/src/provider-auth.ts +336 -0
  88. package/src/provider-config.ts +89 -18
  89. package/src/provider-model.ts +86 -0
  90. package/src/provider-payload.ts +263 -0
  91. package/src/provider-transport.ts +51 -0
  92. package/src/provider-usage.ts +64 -0
  93. package/src/repository-snapshots.ts +259 -0
  94. package/src/service.ts +446 -11
  95. package/src/store.ts +60 -4
  96. package/src/supervisor.ts +294 -41
  97. package/src/task-budget.ts +24 -11
  98. package/src/task-context.ts +17 -0
  99. package/src/task-driver.ts +51 -15
  100. package/src/task-policy.ts +23 -3
  101. package/src/task-verification.ts +2 -0
  102. package/src/task-work.ts +46 -1
  103. package/src/team-work.ts +554 -0
  104. package/src/tui-client.ts +5 -0
  105. package/src/tui-view.ts +164 -12
  106. package/src/tui.ts +5 -2
  107. package/src/usage.ts +17 -0
  108. package/src/worker-boundary.ts +395 -0
  109. package/src/worker-workspaces.ts +390 -0
  110. package/src/workspaces.ts +4 -2
@@ -0,0 +1,370 @@
1
+ import { readFileSync, lstatSync, realpathSync, openSync, closeSync, fstatSync, constants, } from 'node:fs';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isIP, BlockList } from 'node:net';
5
+ import { timingSafeEqual, randomUUID } from 'node:crypto';
6
+ import { digest, ServiceError } from './protocol.js';
7
+ import { repositoryPath } from './repository-snapshots.js';
8
+ export const A2A_PIN = {
9
+ specification: '1.0.0',
10
+ wire: '1.0',
11
+ sdk: '1.0.1',
12
+ transport: 'JSONRPC',
13
+ };
14
+ const fail = (message) => {
15
+ throw new ServiceError('a2a_contract', message);
16
+ };
17
+ export function a2aObject(value) {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value))
19
+ return fail('Expected an A2A object');
20
+ return value;
21
+ }
22
+ export function a2aIdentity(value) {
23
+ if (typeof value !== 'string' ||
24
+ !value.length ||
25
+ value.length > 256 ||
26
+ /[\x00-\x1f\x7f]/.test(value))
27
+ return fail('Invalid A2A identity');
28
+ return value;
29
+ }
30
+ export function privateJSON(path) {
31
+ if (realpathSync(path) !== path)
32
+ return fail('Configuration must use its canonical absolute path');
33
+ const s = lstatSync(path);
34
+ if (!s.isFile() ||
35
+ s.nlink !== 1 ||
36
+ s.size > 262144 ||
37
+ s.mode & 0o077 ||
38
+ s.uid !== process.getuid?.())
39
+ return fail('Peer configuration and credentials must be owner-only regular files');
40
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
41
+ try {
42
+ const before = fstatSync(fd);
43
+ if (before.dev !== s.dev ||
44
+ before.ino !== s.ino ||
45
+ before.nlink !== 1 ||
46
+ before.size !== s.size ||
47
+ before.uid !== s.uid ||
48
+ before.mode !== s.mode)
49
+ return fail('Peer configuration changed during admission');
50
+ const bytes = readFileSync(fd, 'utf8'), after = fstatSync(fd);
51
+ if (after.size !== before.size ||
52
+ after.mtimeMs !== before.mtimeMs ||
53
+ after.ctimeMs !== before.ctimeMs)
54
+ return fail('Peer configuration changed while reading');
55
+ return JSON.parse(bytes);
56
+ }
57
+ finally {
58
+ closeSync(fd);
59
+ }
60
+ }
61
+ export function peerCredential(peer) {
62
+ const value = a2aObject(privateJSON(peer.credential_file));
63
+ if (Object.keys(value).sort().join(',') !== 'peer,token' ||
64
+ value.peer !== peer.id ||
65
+ typeof value.token !== 'string' ||
66
+ !/^[A-Za-z0-9._~-]{32,512}$/.test(value.token) ||
67
+ digest(value) !== peer.credential_digest)
68
+ return fail('Peer credential identity changed; renew admission');
69
+ return value.token;
70
+ }
71
+ export function equalToken(a, b) {
72
+ const x = Buffer.from(a), y = Buffer.from(b);
73
+ return x.length === y.length && timingSafeEqual(x, y);
74
+ }
75
+ export function validatePeer(value) {
76
+ const p = a2aObject(value);
77
+ const keys = [
78
+ 'id',
79
+ 'endpoint',
80
+ 'card_url',
81
+ 'card_digest',
82
+ 'tenant',
83
+ 'skill',
84
+ 'addresses',
85
+ 'local',
86
+ 'credential_file',
87
+ 'credential_digest',
88
+ 'files',
89
+ 'retention',
90
+ 'cancel',
91
+ 'streaming',
92
+ ];
93
+ if (Object.keys(p).sort().join(',') !== keys.sort().join(','))
94
+ return fail('Unknown or missing peer configuration field');
95
+ for (const key of ['id', 'skill'])
96
+ a2aIdentity(p[key]);
97
+ if (typeof p.tenant !== 'string' || p.tenant.length > 256 || /[\x00-\x1f\x7f]/.test(p.tenant))
98
+ return fail('Invalid tenant');
99
+ for (const key of ['card_digest', 'credential_digest'])
100
+ if (!/^[a-f0-9]{64}$/.test(p[key]))
101
+ return fail('Expected reviewed SHA-256 digest');
102
+ if (typeof p.local !== 'boolean' ||
103
+ typeof p.cancel !== 'boolean' ||
104
+ typeof p.streaming !== 'boolean' ||
105
+ !['volatile', 'durable', 'unknown'].includes(p.retention))
106
+ return fail('Explicit peer capability and retention policy required');
107
+ const endpoint = new URL(p.endpoint), card = new URL(p.card_url);
108
+ for (const u of [endpoint, card]) {
109
+ if (u.username ||
110
+ u.password ||
111
+ u.search ||
112
+ u.hash ||
113
+ u.href !== (u === endpoint ? p.endpoint : p.card_url))
114
+ return fail('Canonical peer URLs cannot contain credentials, queries or fragments');
115
+ if (u.protocol !== 'https:' &&
116
+ !(p.local && u.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(u.hostname)))
117
+ return fail('HTTPS required except explicitly configured loopback rehearsal');
118
+ }
119
+ if (endpoint.origin !== card.origin)
120
+ return fail('Agent Card must share the configured origin');
121
+ const denied = new BlockList();
122
+ denied.addSubnet('169.254.0.0', 16);
123
+ denied.addSubnet('0.0.0.0', 8);
124
+ denied.addSubnet('224.0.0.0', 4);
125
+ denied.addSubnet('fe80::', 10, 'ipv6');
126
+ denied.addSubnet('ff00::', 8, 'ipv6');
127
+ denied.addAddress('::', 'ipv6');
128
+ if (!Array.isArray(p.addresses) ||
129
+ !p.addresses.length ||
130
+ p.addresses.length > 8 ||
131
+ new Set(p.addresses).size !== p.addresses.length)
132
+ return fail('Pin explicit destination IP addresses');
133
+ for (const address of p.addresses) {
134
+ const family = isIP(address);
135
+ if (!family ||
136
+ address.includes('%') ||
137
+ denied.check(address, family === 6 ? 'ipv6' : 'ipv4') ||
138
+ (!p.local && (address.startsWith('127.') || address === '::1')))
139
+ return fail('Forbidden peer destination');
140
+ if (family === 6 && address.toLowerCase().includes('ffff:'))
141
+ return fail('Mapped destination addresses are unsupported');
142
+ if (p.local && !['127.0.0.1', '::1'].includes(address))
143
+ return fail('Local rehearsal must remain on loopback');
144
+ }
145
+ if (isIP(endpoint.hostname.replace(/^\[|\]$/g, '')) &&
146
+ !p.addresses.includes(endpoint.hostname.replace(/^\[|\]$/g, '')))
147
+ return fail('URL literal must match the pinned address');
148
+ if (!Array.isArray(p.files) || p.files.length > 64 || new Set(p.files).size !== p.files.length)
149
+ return fail('Bound explicit disclosure paths');
150
+ for (const path of p.files)
151
+ repositoryPath(path);
152
+ peerCredential(p);
153
+ return structuredClone(p);
154
+ }
155
+ /** No redirects, DNS fallback, cookies, inherited proxy or provider credentials. */
156
+ export class A2AHttp {
157
+ peer;
158
+ constructor(peer) {
159
+ this.peer = peer;
160
+ }
161
+ async request(url, body) {
162
+ const p = validatePeer(this.peer), u = new URL(url);
163
+ if (url !== p.endpoint && url !== p.card_url)
164
+ return fail('Unconfigured A2A destination');
165
+ const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
166
+ if (bytes && bytes.length > 262144)
167
+ return fail('A2A disclosure exceeds request bound');
168
+ const token = peerCredential(p);
169
+ return new Promise((resolve, reject) => {
170
+ const request = (u.protocol === 'https:' ? httpsRequest : httpRequest)(u, {
171
+ method: bytes ? 'POST' : 'GET',
172
+ agent: false,
173
+ headers: {
174
+ Authorization: `Bearer ${token}`,
175
+ 'A2A-Version': A2A_PIN.wire,
176
+ Accept: 'application/json',
177
+ ...(bytes
178
+ ? { 'Content-Type': 'application/json', 'Content-Length': String(bytes.length) }
179
+ : {}),
180
+ },
181
+ lookup: (_hostname, options, callback) => {
182
+ const address = p.addresses[0];
183
+ if (typeof options === 'object' && options.all)
184
+ callback(null, [{ address, family: isIP(address) }]);
185
+ else
186
+ callback(null, address, isIP(address));
187
+ },
188
+ }, (response) => {
189
+ if (response.statusCode !== 200 ||
190
+ !/^application\/json(?:;|$)/i.test(String(response.headers['content-type']))) {
191
+ response.destroy();
192
+ reject(Error('Peer returned an unsupported status or media type'));
193
+ return;
194
+ }
195
+ if (response.headers['a2a-version'] && response.headers['a2a-version'] !== A2A_PIN.wire) {
196
+ response.destroy();
197
+ reject(Error('Peer wire version changed'));
198
+ return;
199
+ }
200
+ let size = 0;
201
+ const chunks = [];
202
+ response.on('data', (chunk) => {
203
+ size += chunk.length;
204
+ if (size > 1048576)
205
+ response.destroy(Error('Peer response exceeds bound'));
206
+ else
207
+ chunks.push(chunk);
208
+ });
209
+ response.on('error', reject);
210
+ response.on('end', () => {
211
+ try {
212
+ resolve(a2aObject(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
213
+ }
214
+ catch (e) {
215
+ reject(e);
216
+ }
217
+ });
218
+ });
219
+ const timeout = body && a2aObject(body).method === 'SendMessage' ? 60000 : 10000;
220
+ const deadline = setTimeout(() => request.destroy(Error('A2A absolute request deadline exceeded; acceptance may be unknown')), timeout);
221
+ request.once('close', () => clearTimeout(deadline));
222
+ request.setTimeout(timeout, () => request.destroy(Error('A2A request timed out; acceptance may be unknown')));
223
+ request.on('error', reject);
224
+ request.end(bytes);
225
+ });
226
+ }
227
+ async card() {
228
+ return this.assertCard(await this.request(this.peer.card_url));
229
+ }
230
+ assertCard(card) {
231
+ if (digest(card) !== this.peer.card_digest)
232
+ return fail('Agent Card changed; operator review required');
233
+ if (!Array.isArray(card.supportedInterfaces) ||
234
+ !card.supportedInterfaces.length ||
235
+ card.supportedInterfaces.some((i) => i.url !== this.peer.endpoint ||
236
+ i.protocolBinding !== 'JSONRPC' ||
237
+ i.protocolVersion !== '1.0' ||
238
+ (i.tenant ?? '') !== this.peer.tenant))
239
+ return fail('Agent Card interface differs from configured peer/version/tenant');
240
+ if (!Array.isArray(card.skills) || !card.skills.some((s) => s.id === this.peer.skill))
241
+ return fail('Configured peer skill is absent');
242
+ const caps = a2aObject(card.capabilities);
243
+ if (this.peer.streaming && caps.streaming !== true)
244
+ return fail('Streaming capability changed');
245
+ return card;
246
+ }
247
+ async subscribe(task, consume) {
248
+ const p = validatePeer(this.peer), u = new URL(p.endpoint), id = randomUUID();
249
+ const bytes = Buffer.from(JSON.stringify({
250
+ jsonrpc: '2.0',
251
+ id,
252
+ method: 'SubscribeToTask',
253
+ params: { id: task, tenant: p.tenant },
254
+ }));
255
+ await new Promise((resolve, reject) => {
256
+ const request = (u.protocol === 'https:' ? httpsRequest : httpRequest)(u, {
257
+ method: 'POST',
258
+ agent: false,
259
+ headers: {
260
+ Authorization: `Bearer ${peerCredential(p)}`,
261
+ 'A2A-Version': '1.0',
262
+ Accept: 'text/event-stream',
263
+ 'Content-Type': 'application/json',
264
+ 'Content-Length': String(bytes.length),
265
+ },
266
+ lookup: (_hostname, options, callback) => {
267
+ const address = p.addresses[0];
268
+ if (typeof options === 'object' && options.all)
269
+ callback(null, [{ address, family: isIP(address) }]);
270
+ else
271
+ callback(null, address, isIP(address));
272
+ },
273
+ }, (response) => {
274
+ if (response.statusCode !== 200 ||
275
+ !/^text\/event-stream(?:;|$)/i.test(String(response.headers['content-type'])) ||
276
+ (response.headers['a2a-version'] && response.headers['a2a-version'] !== '1.0')) {
277
+ response.destroy();
278
+ reject(Error('Peer streaming unavailable'));
279
+ return;
280
+ }
281
+ const decoder = new TextDecoder('utf-8', { fatal: true });
282
+ let buffer = '', size = 0, events = 0, terminalSeen = false, done = false;
283
+ response.on('error', reject);
284
+ response.on('data', (chunk) => {
285
+ try {
286
+ size += chunk.length;
287
+ if (size > 1048576)
288
+ throw Error('Peer stream exceeds bound');
289
+ buffer += decoder.decode(chunk, { stream: true });
290
+ if (buffer.length > 524288)
291
+ throw Error('Peer SSE frame exceeds bound');
292
+ for (;;) {
293
+ const match = /\r?\n\r?\n/.exec(buffer);
294
+ if (!match)
295
+ break;
296
+ const frame = buffer.slice(0, match.index);
297
+ buffer = buffer.slice(match.index + match[0].length);
298
+ const data = frame
299
+ .split(/\r?\n/)
300
+ .filter((line) => line.startsWith('data:'))
301
+ .map((line) => line.slice(5).trimStart())
302
+ .join('\n');
303
+ if (!data)
304
+ continue;
305
+ if (done)
306
+ throw new ServiceError('a2a_identity', 'Stream continued after its terminator');
307
+ if (data === '[DONE]') {
308
+ if (!terminalSeen)
309
+ throw new ServiceError('a2a_identity', 'Premature stream terminator');
310
+ done = true;
311
+ continue;
312
+ }
313
+ if (++events > 128)
314
+ throw Error('Peer stream event bound exceeded');
315
+ const event = a2aObject(JSON.parse(data));
316
+ if (event.jsonrpc !== '2.0' || event.id !== id)
317
+ throw new ServiceError('a2a_identity', 'Unowned stream response');
318
+ if (event.error)
319
+ throw Error('Peer stream operation failed');
320
+ const result = a2aObject(event.result);
321
+ consume(result);
322
+ if ([
323
+ 'TASK_STATE_COMPLETED',
324
+ 'TASK_STATE_FAILED',
325
+ 'TASK_STATE_CANCELED',
326
+ 'TASK_STATE_REJECTED',
327
+ ].includes(result.statusUpdate?.status?.state ?? result.task?.status?.state))
328
+ terminalSeen = true;
329
+ }
330
+ }
331
+ catch (e) {
332
+ response.destroy();
333
+ request.destroy();
334
+ reject(e);
335
+ }
336
+ });
337
+ response.on('end', () => {
338
+ try {
339
+ decoder.decode();
340
+ if (buffer.trim())
341
+ throw Error('Peer stream ended mid-frame');
342
+ resolve();
343
+ }
344
+ catch (e) {
345
+ reject(e);
346
+ }
347
+ });
348
+ });
349
+ const deadline = setTimeout(() => request.destroy(Error('A2A stream deadline; reconcile existing task')), 15000);
350
+ request.once('close', () => clearTimeout(deadline));
351
+ request.on('error', reject);
352
+ request.end(bytes);
353
+ });
354
+ }
355
+ async rpc(method, params) {
356
+ const id = randomUUID(), response = await this.request(this.peer.endpoint, {
357
+ jsonrpc: '2.0',
358
+ id,
359
+ method,
360
+ params: { ...params, tenant: this.peer.tenant },
361
+ });
362
+ if (response.jsonrpc !== '2.0' ||
363
+ response.id !== id ||
364
+ Object.hasOwn(response, 'error') === Object.hasOwn(response, 'result'))
365
+ return fail('Unowned or malformed A2A response');
366
+ if (response.error)
367
+ throw new ServiceError('a2a_remote_error', `Peer error ${Number(response.error.code)}`);
368
+ return a2aObject(response.result);
369
+ }
370
+ }
@@ -0,0 +1,250 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { digest, canonical, ServiceError } from './protocol.js';
3
+ import { a2aIdentity, a2aObject } from './a2a-peer.js';
4
+ export const A2A_TERMINAL = new Set([
5
+ 'TASK_STATE_COMPLETED',
6
+ 'TASK_STATE_FAILED',
7
+ 'TASK_STATE_CANCELED',
8
+ 'TASK_STATE_REJECTED',
9
+ ]);
10
+ const STATES = new Set([
11
+ ...A2A_TERMINAL,
12
+ 'TASK_STATE_SUBMITTED',
13
+ 'TASK_STATE_WORKING',
14
+ 'TASK_STATE_INPUT_REQUIRED',
15
+ 'TASK_STATE_AUTH_REQUIRED',
16
+ ]);
17
+ export function a2aTask(value) {
18
+ const task = a2aObject(value);
19
+ a2aIdentity(task.id);
20
+ a2aIdentity(task.contextId);
21
+ const status = a2aObject(task.status);
22
+ if (!STATES.has(status.state) || canonical(task).length > 524288)
23
+ throw new ServiceError('a2a_task', 'Unsupported task state or size');
24
+ const artifacts = task.artifacts ?? [], ids = new Set();
25
+ if (!Array.isArray(artifacts) || artifacts.length > 64)
26
+ throw Error('Unbounded remote artifacts');
27
+ for (const artifact of artifacts) {
28
+ const id = a2aIdentity(a2aObject(artifact).artifactId);
29
+ if (ids.has(id))
30
+ throw Error('Duplicate remote artifact identity');
31
+ ids.add(id);
32
+ validateParts(artifact.parts);
33
+ }
34
+ // Remote URIs are never fetched, even if authenticated or advertised as files.
35
+ for (const message of task.history ?? []) {
36
+ if (!['ROLE_AGENT', 'ROLE_USER'].includes(message.role))
37
+ throw Error('Unknown remote message role');
38
+ validateParts(message.parts);
39
+ }
40
+ if (status.message)
41
+ validateParts(status.message.parts);
42
+ return structuredClone(task);
43
+ }
44
+ export function validateParts(value) {
45
+ if (!Array.isArray(value) || value.length > 64)
46
+ throw Error('Unsupported remote parts');
47
+ for (const raw of value) {
48
+ const p = a2aObject(raw), keys = Object.keys(p).filter((k) => !['metadata', 'mediaType'].includes(k));
49
+ if (keys.length !== 1 ||
50
+ !['text', 'data'].includes(keys[0]) ||
51
+ (keys[0] === 'text' && typeof p.text !== 'string') ||
52
+ (keys[0] === 'data' && (!p.data || typeof p.data !== 'object' || Array.isArray(p.data))))
53
+ throw Error('Only bounded inline text/data parts are admitted');
54
+ if (p.mediaType !== undefined &&
55
+ p.mediaType !== (keys[0] === 'text' ? 'text/plain' : 'application/json'))
56
+ throw Error('Unsupported inline part media type');
57
+ if (canonical(p).length > 131072)
58
+ throw Error('Remote part exceeds bound');
59
+ }
60
+ }
61
+ function terminalContent(task) {
62
+ return {
63
+ id: task.id,
64
+ contextId: task.contextId,
65
+ status: {
66
+ state: task.status.state,
67
+ message: task.status.message
68
+ ? {
69
+ role: task.status.message.role,
70
+ parts: task.status.message.parts,
71
+ metadata: task.status.message.metadata ?? null,
72
+ }
73
+ : null,
74
+ },
75
+ artifacts: (task.artifacts ?? []).map(({ artifactId: _, ...content }) => content),
76
+ metadata: task.metadata ?? null,
77
+ };
78
+ }
79
+ /** Dispatch is never retried. Reconnect reads only the saved peer/task/context. */
80
+ export class A2AInvocation {
81
+ peer;
82
+ rpc;
83
+ save;
84
+ cp;
85
+ broken = false;
86
+ constructor(peer, checkpoint, rpc, save) {
87
+ this.peer = peer;
88
+ this.rpc = rpc;
89
+ this.save = save;
90
+ if (checkpoint.dialect !== 'a2a' ||
91
+ checkpoint.schema_version !== 1 ||
92
+ checkpoint.peer_id !== peer.id ||
93
+ checkpoint.peer_digest !== peer.digest ||
94
+ checkpoint.tenant !== peer.tenant)
95
+ throw Error('A2A peer ownership changed');
96
+ this.cp = structuredClone(checkpoint);
97
+ if (['dispatching', 'running'].includes(this.cp.state))
98
+ this.update({ state: 'unknown' });
99
+ }
100
+ view() {
101
+ return structuredClone(this.cp);
102
+ }
103
+ update(change) {
104
+ if (this.broken)
105
+ throw Error('A2A checkpoint control failed');
106
+ const next = { ...this.cp, ...change };
107
+ try {
108
+ if (this.save(structuredClone(next)) !== undefined)
109
+ throw Error('A2A checkpoint must commit synchronously');
110
+ }
111
+ catch (e) {
112
+ this.broken = true;
113
+ throw e;
114
+ }
115
+ this.cp = next;
116
+ }
117
+ accept(value) {
118
+ const task = a2aTask(value);
119
+ if ((this.cp.task_id && task.id !== this.cp.task_id) ||
120
+ (this.cp.context_id && task.contextId !== this.cp.context_id))
121
+ throw Error('Remote task/context ownership mismatch');
122
+ if (this.cp.state === 'terminal' &&
123
+ digest(terminalContent(task)) !== digest(terminalContent(this.cp.result)))
124
+ throw Error('Conflicting remote terminal snapshot');
125
+ const artifact_receipts = (task.artifacts ?? []).map((artifact, index) => {
126
+ const { artifactId, ...content } = artifact, content_digest = digest(content), id = digest({ task_id: task.id, index, content_digest });
127
+ const old = this.cp.artifact_receipts?.find((a) => a.id === id);
128
+ return {
129
+ id,
130
+ content_digest,
131
+ upstream_ids: [...new Set([...(old?.upstream_ids ?? []), artifactId])].slice(-8),
132
+ };
133
+ });
134
+ this.update({
135
+ task_id: task.id,
136
+ context_id: task.contextId,
137
+ result: task,
138
+ artifact_receipts,
139
+ state: A2A_TERMINAL.has(task.status.state) ? 'terminal' : 'running',
140
+ });
141
+ return task;
142
+ }
143
+ async dispatch(text, data) {
144
+ if (this.cp.state !== 'prepared' ||
145
+ this.cp.cancel_requested ||
146
+ this.cp.prompt_digest !== digest({ text, data }))
147
+ throw Error('A2A invocation cannot dispatch again');
148
+ this.update({ state: 'dispatching' });
149
+ try {
150
+ const result = await this.rpc.rpc('SendMessage', {
151
+ message: {
152
+ messageId: this.cp.message_id,
153
+ role: 'ROLE_USER',
154
+ parts: [{ text }, { data }],
155
+ metadata: { 'salaam.skill': this.peer.skill },
156
+ },
157
+ configuration: {
158
+ acceptedOutputModes: ['text/plain', 'application/json'],
159
+ returnImmediately: true,
160
+ historyLength: 0,
161
+ },
162
+ });
163
+ if (!result.task || result.message)
164
+ throw Error('A durable remote task receipt is required');
165
+ return this.accept(result.task);
166
+ }
167
+ catch (e) {
168
+ this.update({ state: 'unknown' });
169
+ throw e;
170
+ }
171
+ }
172
+ async inspect() {
173
+ if (!this.cp.task_id || !this.cp.context_id || this.broken)
174
+ throw Error('Remote acceptance identity is unknown; prompt will not be resent');
175
+ const result = await this.rpc.rpc('GetTask', { id: this.cp.task_id, historyLength: 0 });
176
+ return this.accept(result);
177
+ }
178
+ /** Stream chunks are tentative. Reconnect always reconciles an authoritative task snapshot. */
179
+ observe(raw) {
180
+ const event = a2aObject(raw), keys = Object.keys(event);
181
+ if (keys.length !== 1 || !['task', 'statusUpdate', 'artifactUpdate'].includes(keys[0]))
182
+ throw new ServiceError('a2a_identity', 'Unsupported stream payload');
183
+ const value = a2aObject(event[keys[0]]);
184
+ if ((keys[0] === 'task' ? value.id : value.taskId) !== this.cp.task_id ||
185
+ value.contextId !== this.cp.context_id)
186
+ throw new ServiceError('a2a_identity', 'Unowned stream task/context');
187
+ const progress = structuredClone(this.cp.progress ?? { remote_state: null, artifact_ids: [] });
188
+ if (keys[0] === 'task') {
189
+ const task = a2aTask(value);
190
+ progress.remote_state = task.status.state;
191
+ progress.artifact_ids = task.artifacts?.map((a) => a.artifactId) ?? [];
192
+ }
193
+ else if (keys[0] === 'statusUpdate') {
194
+ if (!STATES.has(value.status?.state))
195
+ throw new ServiceError('a2a_identity', 'Unknown stream state');
196
+ progress.remote_state = value.status.state;
197
+ }
198
+ else {
199
+ a2aIdentity(value.artifact?.artifactId);
200
+ validateParts(value.artifact.parts);
201
+ if (!progress.artifact_ids.includes(value.artifact.artifactId))
202
+ progress.artifact_ids.push(value.artifact.artifactId);
203
+ if (progress.artifact_ids.length > 64)
204
+ throw new ServiceError('a2a_identity', 'Unbounded stream artifacts');
205
+ }
206
+ if (this.cp.state !== 'terminal')
207
+ this.update({ progress });
208
+ }
209
+ async cancel() {
210
+ if (this.cp.state === 'terminal')
211
+ return;
212
+ this.update({ cancel_requested: true, cancel_state: 'intent' });
213
+ if (!this.cp.task_id)
214
+ throw Error('Unknown remote acceptance cannot be canceled safely');
215
+ await this.inspect();
216
+ if (this.view().state === 'terminal')
217
+ return;
218
+ if (!this.peer.cancel) {
219
+ this.update({ cancel_state: 'unsupported' });
220
+ if (this.view().state !== 'terminal')
221
+ throw Error('Peer cancellation is unsupported; remote stop remains unconfirmed');
222
+ return;
223
+ }
224
+ this.update({ cancel_state: 'dispatching' });
225
+ const task = await this.rpc.rpc('CancelTask', { id: this.cp.task_id });
226
+ this.accept(task);
227
+ this.update({ cancel_state: 'acknowledged' });
228
+ if (this.view().state !== 'terminal')
229
+ throw Error('Cancellation acknowledgement does not confirm remote stop');
230
+ }
231
+ static prepare(worker, attempt, peer, text, data, disclosure) {
232
+ return {
233
+ schema_version: 1,
234
+ dialect: 'a2a',
235
+ worker_id: worker,
236
+ attempt_id: attempt,
237
+ peer_id: peer.id,
238
+ peer_digest: peer.digest,
239
+ tenant: peer.tenant,
240
+ message_id: randomUUID(),
241
+ task_id: null,
242
+ context_id: null,
243
+ state: 'prepared',
244
+ prompt_digest: digest({ text, data }),
245
+ cancel_requested: false,
246
+ result: null,
247
+ disclosure,
248
+ };
249
+ }
250
+ }