@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.1

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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +1 -0
  3. package/README.md +124 -2
  4. package/THIRD_PARTY_NOTICES.md +66 -0
  5. package/ZCode-LICENSE +202 -0
  6. package/dist/index.mjs +4871 -0
  7. package/dist/native-command-plan.LICENSE.txt +221 -0
  8. package/dist/native-command-plan.json +16 -0
  9. package/dist/native-command-plan.mjs +8 -0
  10. package/dist/rpc-entry.mjs +5682 -0
  11. package/package.json +169 -4
  12. package/src/native/a2a-receipt-source.mjs +19 -0
  13. package/src/native/a2a-tools.mjs +517 -0
  14. package/src/native/activity-status.mjs +30 -0
  15. package/src/native/artifact-codec.mjs +481 -0
  16. package/src/native/artifact-store.mjs +1162 -0
  17. package/src/native/assistant-stream.mjs +76 -0
  18. package/src/native/background-command-port.mjs +95 -0
  19. package/src/native/browser-action-observation.mjs +43 -0
  20. package/src/native/browser-availability.mjs +22 -0
  21. package/src/native/browser-canvas-metrics.mjs +71 -0
  22. package/src/native/browser-dom.mjs +193 -0
  23. package/src/native/browser-playwright.mjs +1249 -0
  24. package/src/native/browser-vision.mjs +369 -0
  25. package/src/native/browser-workspace-policy.mjs +22 -0
  26. package/src/native/browser-workspace-protocol.mjs +95 -0
  27. package/src/native/browser-workspace-server.mjs +164 -0
  28. package/src/native/browser-workspace.mjs +355 -0
  29. package/src/native/captured-source.mjs +3 -0
  30. package/src/native/child-budget-allocation.mjs +87 -0
  31. package/src/native/child-capabilities.mjs +35 -0
  32. package/src/native/child-command-resource.mjs +20 -0
  33. package/src/native/child-history.mjs +188 -0
  34. package/src/native/child-merge.mjs +98 -0
  35. package/src/native/child-model-surface.mjs +19 -0
  36. package/src/native/child-observation.mjs +100 -0
  37. package/src/native/child-owner.mjs +620 -0
  38. package/src/native/child-publication.mjs +76 -0
  39. package/src/native/child-readers.mjs +366 -0
  40. package/src/native/child-tools.mjs +46 -0
  41. package/src/native/child-workspace-binding.mjs +29 -0
  42. package/src/native/child-workspace-tools.mjs +1 -0
  43. package/src/native/child-workspaces.mjs +655 -0
  44. package/src/native/children.mjs +365 -0
  45. package/src/native/command-plan-worker.mjs +14 -0
  46. package/src/native/command-plan.mjs +31 -0
  47. package/src/native/command-progress.mjs +64 -0
  48. package/src/native/command-shell.mjs +9 -0
  49. package/src/native/command-workspace-observation.mjs +36 -0
  50. package/src/native/component-tools.mjs +280 -0
  51. package/src/native/context-counter.mjs +133 -0
  52. package/src/native/context-markers.mjs +51 -0
  53. package/src/native/context-profile.mjs +72 -0
  54. package/src/native/context-status.mjs +27 -0
  55. package/src/native/conversation-command-control.mjs +133 -0
  56. package/src/native/delivery-timeline.mjs +39 -0
  57. package/src/native/dsh-background-ownership.mjs +102 -0
  58. package/src/native/dsh-managed-ownership.mjs +23 -0
  59. package/src/native/dsh-turn-contract.mjs +3 -0
  60. package/src/native/dsh-turn-ownership.mjs +54 -0
  61. package/src/native/dsh-turn-runtime.mjs +498 -0
  62. package/src/native/e2b-background-supervisor.mjs +347 -0
  63. package/src/native/e2b-background.mjs +368 -0
  64. package/src/native/e2b-command-state.mjs +40 -0
  65. package/src/native/e2b-command-tools.mjs +486 -0
  66. package/src/native/e2b-sandbox-owner.mjs +227 -0
  67. package/src/native/hook-executor.mjs +249 -0
  68. package/src/native/instruction-assembly.mjs +60 -0
  69. package/src/native/instruction-context.mjs +54 -0
  70. package/src/native/local-background-supervisor.mjs +108 -0
  71. package/src/native/local-background.mjs +207 -0
  72. package/src/native/local-command-evidence.mjs +57 -0
  73. package/src/native/local-command-policy-worker.mjs +197 -0
  74. package/src/native/local-command-policy.mjs +46 -0
  75. package/src/native/local-command-state.mjs +177 -0
  76. package/src/native/local-command-tools.mjs +302 -0
  77. package/src/native/local-execution-profile.mjs +36 -0
  78. package/src/native/local-output-policy.mjs +92 -0
  79. package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
  80. package/src/native/local-process-bin/darwin-x64/launcher +0 -0
  81. package/src/native/local-process-bin/linux-arm64/launcher +0 -0
  82. package/src/native/local-process-bin/linux-x64/launcher +0 -0
  83. package/src/native/local-process-launcher.c +183 -0
  84. package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
  85. package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
  86. package/src/native/local-process-licenses/NOTICE +21 -0
  87. package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
  88. package/src/native/local-process-manifest.json +32 -0
  89. package/src/native/local-process.mjs +246 -0
  90. package/src/native/local-resource-profile.mjs +32 -0
  91. package/src/native/memory-candidate.mjs +121 -0
  92. package/src/native/memory-extraction-run.mjs +60 -0
  93. package/src/native/memory-extraction-tools.mjs +116 -0
  94. package/src/native/memory-recall.mjs +65 -0
  95. package/src/native/memory-store.mjs +334 -0
  96. package/src/native/memory-tools.mjs +329 -0
  97. package/src/native/model-anthropic-cache.mjs +49 -0
  98. package/src/native/model-anthropic-tool-search.mjs +85 -0
  99. package/src/native/model-anthropic.mjs +753 -0
  100. package/src/native/model-chat-usage.mjs +24 -0
  101. package/src/native/model-inline-reasoning.mjs +111 -0
  102. package/src/native/model-media.mjs +174 -0
  103. package/src/native/model-normalize.mjs +228 -0
  104. package/src/native/model-openai-chat.mjs +561 -0
  105. package/src/native/model-port.mjs +361 -0
  106. package/src/native/model-provider-compat.mjs +129 -0
  107. package/src/native/model-replay.mjs +153 -0
  108. package/src/native/model-request.mjs +15 -0
  109. package/src/native/model-responses.mjs +1205 -0
  110. package/src/native/model-sse.mjs +171 -0
  111. package/src/native/model-surface-host.mjs +27 -0
  112. package/src/native/model-tool-search-policy.mjs +16 -0
  113. package/src/native/model-transport-observation.mjs +27 -0
  114. package/src/native/owned-child.mjs +114 -0
  115. package/src/native/owned-command.mjs +106 -0
  116. package/src/native/python-syntax-worker.mjs +60 -0
  117. package/src/native/python-syntax.mjs +50 -0
  118. package/src/native/read-interruption.mjs +16 -0
  119. package/src/native/result-stream.mjs +63 -0
  120. package/src/native/ruff-ast.mjs +95 -0
  121. package/src/native/run-observation.mjs +128 -0
  122. package/src/native/session-delivery-journal.mjs +96 -0
  123. package/src/native/session-delivery.mjs +434 -0
  124. package/src/native/shell-analysis.mjs +50 -0
  125. package/src/native/skill-disclosure.mjs +404 -0
  126. package/src/native/skill-events.mjs +164 -0
  127. package/src/native/skill-tools.mjs +813 -0
  128. package/src/native/source-page.mjs +73 -0
  129. package/src/native/sqlite-session-inbox.mjs +42 -0
  130. package/src/native/sqlite-store.mjs +617 -0
  131. package/src/native/tool-authority.mjs +99 -0
  132. package/src/native/tool-guidance.mjs +12 -0
  133. package/src/native/tool-observation.mjs +75 -0
  134. package/src/native/tool-states.mjs +14 -0
  135. package/src/native/verification-authority.mjs +37 -0
  136. package/src/native/web-tools.mjs +643 -0
  137. package/src/native/workspace-checkpoints.mjs +312 -0
  138. package/src/native/workspace-context.mjs +61 -0
  139. package/src/native/workspace-display.mjs +39 -0
  140. package/src/native/workspace-edit-match.mjs +19 -0
  141. package/src/native/workspace-edit-worker.mjs +15 -0
  142. package/src/native/workspace-editing.mjs +447 -0
  143. package/src/native/workspace-image.mjs +72 -0
  144. package/src/native/workspace-instructions.mjs +107 -0
  145. package/src/native/workspace-mutation-lock.mjs +74 -0
  146. package/src/native/workspace-patch-worker.mjs +17 -0
  147. package/src/native/workspace-patch.mjs +17 -0
  148. package/src/native/workspace-paths.mjs +225 -0
  149. package/src/native/workspace-state.mjs +272 -0
  150. package/src/native/workspace-subject-observer.mjs +84 -0
  151. package/src/native/workspace-tools.mjs +1740 -0
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Native Node A2A tools for the AgentKit Harness.
3
+ *
4
+ * Exports nativeA2aToolDefinitions() and createNativeA2aToolPort({scope,
5
+ * sessionId, spaceId, discovery, invocation, exposurePlan, authorize,
6
+ * assertCurrent}) -> {definitions, execute, contract}.
7
+ *
8
+ * Three sealed ToolDefinitions ported from ksadk.toolsets.a2a @355059d:
9
+ * list_a2a_agents — read-only discovery of remote agents in the A2A Space.
10
+ * get_a2a_agent_card — read-only agent card lookup by id or name.
11
+ * call_a2a_agent — managed invocation of a remote agent. HIGH risk with
12
+ * side_effect a2a_remote_call. The model provides only agent ref + message;
13
+ * the Host runtime supplies the invocation service, exposure plan, and
14
+ * authorization. The model cannot control URL/credentials/scope/trigger.
15
+ *
16
+ * call_a2a_agent is a managed invocation — it never fetches a model URL
17
+ * directly. The injected `invocation` object provides:
18
+ * resolve({agent, message, scope, sessionId, runId, callId, operationKey,
19
+ * signal}) -> {spec, trigger} — maps the model's agent/message to a
20
+ * fixed target via the exposure plan (no model control over target).
21
+ * dispatch({request, trigger, signal}) -> {status, reply, task_id, agent,
22
+ * output_refs, snapshots, item_kinds}
23
+ * — invokes the remote agent through the managed service and returns the
24
+ * terminal reply selected from authoritative output_refs via
25
+ * selectTerminalReply. The dispatch result must carry a terminal status
26
+ * ('completed'/'failed'/'cancelled'/'rejected'); running/unknown are not
27
+ * success. If the dispatch returns a non-terminal status, the result is
28
+ * uncertain.
29
+ *
30
+ * The exposure plan is REQUIRED and must fail closed: if
31
+ * assertGenericTargetAllowed is missing, returns a thenable, or throws, the
32
+ * call is rejected — the model cannot target arbitrary endpoints.
33
+ * assertGenericTargetAllowed must be synchronous and return void (undefined).
34
+ * A thenable return is observed then immediately rejected — no dispatch.
35
+ *
36
+ * Authorization: every execute enforces full request identity (scope four
37
+ * domains, sessionId, run/turn/build/parent/chain/op/fence, call, definition,
38
+ * budget, interaction, signal). assertCurrent(request)===true is required
39
+ * (thenable observed + rejected). authorize is called before any remote
40
+ * dispatch. call_a2a_agent's dispatch is a side effect: effectStarted is set
41
+ * only immediately before calling dispatch (NOT before resolve/plan/guard).
42
+ * Any throw/abort/drift/unknown-terminal AFTER effectStarted returns
43
+ * 'uncertain' with the original frozen scope/session/run/op locator (captured
44
+ * at entry, before any await), never 'error' that invites a blind retry.
45
+ * Pre-dispatch rejections (resolve/plan/guard) return 'error' with
46
+ * executed:false. Discovery/card are read-only and safely retryable.
47
+ *
48
+ * The locator is frozen at entry from the immutable bound scope — callback
49
+ * tampering of the mutable request cannot pollute the uncertain receipt.
50
+ * The frozen signal (captured at guard construction) is used for abort
51
+ * checks, never the mutable request.signal.
52
+ *
53
+ * Binding digest requires a serializable binding description from the Host:
54
+ * sourceRef, sourceDigest, spaceId, planDigest, transport, version.
55
+ * Missing fields fail closed. No secrets in digest.
56
+ *
57
+ * list/card sanitize agent metadata: only safe fields with strict per-field
58
+ * byte limits, strip userinfo/query from URLs, enforce a total JSON byte
59
+ * budget by iteratively truncating the actual serialized output (not a
60
+ * fixed count). Not-found hints are also byte-bounded.
61
+ */
62
+ import { createHash } from 'node:crypto';
63
+ import { canonical, copy, invariant, toolInputRejection, matchesToolExecutionContract } from '@kingsoftcloud/agentkit-harness-core';
64
+
65
+ export const NATIVE_A2A_TOOLS_CONTRACT = 'agentkit.native-a2a-tools/v1';
66
+ export const DSH_A2A_TOOLS_CONTRACT = 'agentkit.dsh-a2a-tools/v1';
67
+ const lockFailure = code => Object.assign(new Error(code), { code });
68
+ const MAX_AGENT_LIST_BYTES = 256 * 1024; // 256 KiB total JSON budget
69
+ const MAX_AGENT_FIELD_BYTES = 4096; // per-field byte limit
70
+ const MAX_HINT_BYTES = 64 * 1024; // 64 KiB for not-found hints
71
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'rejected']);
72
+ const SAFE_AGENT_FIELDS = new Set(['agent_id', 'name', 'description', 'version_id', 'card_sha256', 'source', 'url']);
73
+
74
+ function freezeJson(value) {
75
+ if (value && typeof value === 'object') { for (const item of Object.values(value)) freezeJson(item); Object.freeze(value); }
76
+ return value;
77
+ }
78
+
79
+ // Cancellation must finish even when an injected service ignores the signal.
80
+ // Observe late rejection, but never resume this call after cancellation.
81
+ function abortable(signal, work) {
82
+ signal.throwIfAborted();
83
+ return new Promise((resolve, reject) => {
84
+ const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason); };
85
+ signal.addEventListener('abort', abort, { once: true });
86
+ let pending;
87
+ try { pending = work(); } catch (error) { pending = Promise.reject(error); }
88
+ Promise.resolve(pending).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
89
+ });
90
+ }
91
+
92
+ function def(name, description, parameters, opts) {
93
+ return {
94
+ name, description, parameters: copy(parameters),
95
+ readOnly: opts.readOnly, concurrentSafe: opts.concurrentSafe,
96
+ permissions: opts.permissions || [], timeoutMs: opts.timeoutMs,
97
+ contextRole: opts.contextRole || 'none', observationRole: 'none',
98
+ progressObservation: false, contextRetention: 'normal',
99
+ };
100
+ }
101
+
102
+ export function nativeA2aToolDefinitions({ receiptObservation = false } = {}) {
103
+ return [
104
+ def('list_a2a_agents', 'List remote A2A agents in the configured A2A Space. Returns each agent\'s id, name, description and version. No remote calls are made to the agents themselves — only discovery metadata.', {
105
+ type: 'object', properties: {}, additionalProperties: false,
106
+ }, { readOnly: true, concurrentSafe: true, timeoutMs: 30_000 }),
107
+ def('get_a2a_agent_card', 'Get the AgentCard of a remote A2A agent by id or name. Accepts the A2AAgentId (a2a-agent-*) or the card name. When not found, returns available_agents and a suggestion.', {
108
+ type: 'object',
109
+ properties: { agent: { type: 'string', description: 'The A2A agent id (a2a-agent-*) or card name.' } },
110
+ required: ['agent'], additionalProperties: false,
111
+ }, { readOnly: true, concurrentSafe: true, timeoutMs: 30_000 }),
112
+ def('call_a2a_agent', 'Invoke a managed remote agent and return its reply. The runtime supplies authorization, actual call identity and a fixed target. The model provides only the agent reference and message.', {
113
+ type: 'object',
114
+ properties: {
115
+ agent: { type: 'string', description: 'The A2A agent id (a2a-agent-*) or card name.' },
116
+ message: { type: 'string', description: 'The message to send to the remote agent.' },
117
+ },
118
+ required: ['agent', 'message'], additionalProperties: false,
119
+ }, { readOnly: false, concurrentSafe: false, permissions: ['a2a:call'], timeoutMs: 90_000 }),
120
+ ].map(tool => receiptObservation && tool.name === 'call_a2a_agent'
121
+ ? { ...tool, recoveryPolicy: 'receipt-observation/v1' } : tool);
122
+ }
123
+
124
+ /** Normalize an agent reference: strip, lower, remove a2a-agent- prefix. */
125
+ function normalizeAgentRef(value) {
126
+ return String(value || '').trim().toLowerCase().replace(/^a2a-agent-/, '');
127
+ }
128
+
129
+ /** Match an agent reference against a list of discovered agents. */
130
+ function matchAgent(agents, ref) {
131
+ const target = normalizeAgentRef(ref);
132
+ if (!target) return { matched: null, hint: agents.map(a => a.agent_id) };
133
+ for (const a of agents) {
134
+ if (normalizeAgentRef(a.agent_id) === target || normalizeAgentRef(a.name) === target) {
135
+ return { matched: a, hint: [] };
136
+ }
137
+ }
138
+ const allNames = [
139
+ ...agents.map(a => normalizeAgentRef(a.agent_id)),
140
+ ...agents.filter(a => a.name).map(a => normalizeAgentRef(a.name)),
141
+ ];
142
+ let suggestion = null;
143
+ for (const name of allNames) {
144
+ if (name.includes(target) || target.includes(name)) { suggestion = name; break; }
145
+ }
146
+ return { matched: null, hint: [...agents.map(a => a.agent_id), ...(suggestion ? [suggestion] : [])] };
147
+ }
148
+
149
+ /** Strip userinfo and query from a URL for safe display. Only origin+path. */
150
+ function sanitizeUrl(url) {
151
+ const value = String(url || '');
152
+ if (!value) return '';
153
+ try {
154
+ const parsed = new URL(value);
155
+ parsed.username = ''; parsed.password = ''; parsed.search = ''; parsed.hash = '';
156
+ return parsed.toString();
157
+ } catch { return ''; } // invalid URL → empty, never echo raw
158
+ }
159
+
160
+ /** Sanitize agent metadata: only allow safe fields, strip secrets, truncate
161
+ * per-field UTF-8 bytes (not JS chars), sanitize URLs. */
162
+ function sanitizeAgent(agent) {
163
+ const safe = {};
164
+ for (const key of SAFE_AGENT_FIELDS) {
165
+ if (agent[key] == null) continue;
166
+ if (key === 'url') { safe[key] = truncateUtf8(sanitizeUrl(agent[key]), MAX_AGENT_FIELD_BYTES); }
167
+ else { safe[key] = truncateUtf8(String(agent[key]), MAX_AGENT_FIELD_BYTES); }
168
+ }
169
+ return safe;
170
+ }
171
+
172
+ /** Truncate a string to maxBytes UTF-8, not cutting mid-codepoint. */
173
+ function truncateUtf8(str, maxBytes) {
174
+ const buf = Buffer.from(str, 'utf8');
175
+ if (buf.byteLength <= maxBytes) return str;
176
+ // Find the last valid UTF-8 boundary at or before maxBytes.
177
+ let cut = maxBytes;
178
+ // Walk back to a valid boundary (not a continuation byte 10xxxxxx).
179
+ while (cut > 0 && (buf[cut] & 0xC0) === 0x80) cut--;
180
+ return buf.subarray(0, cut).toString('utf8');
181
+ }
182
+
183
+ /** Bounded agent list: iteratively truncate the ACTUAL serialized JSON of the
184
+ * complete content envelope (not just the agents array) until it is under
185
+ * the byte budget. The budget covers the full { ok, space_id, agents }
186
+ * object with JSON escaping. */
187
+ function sanitizeAgentList(agents, spaceId, maxBytes = MAX_AGENT_LIST_BYTES) {
188
+ const envelope = agents => ({ ok: true, space_id: spaceId, agents });
189
+ if (Buffer.byteLength(JSON.stringify(envelope([])), 'utf8') > maxBytes) {
190
+ return { ok: false, error_type: 'a2a_discovery_result_too_large', error_message: 'Configured discovery metadata exceeds the output byte limit' };
191
+ }
192
+ const sanitized = (agents || []).filter(a => a && typeof a === 'object').map(sanitizeAgent);
193
+ // Binary search for the largest prefix where the full envelope fits.
194
+ let lo = 0, hi = sanitized.length, best = 0;
195
+ while (lo <= hi) {
196
+ const mid = Math.floor((lo + hi) / 2);
197
+ const json = JSON.stringify(envelope(sanitized.slice(0, mid)));
198
+ if (Buffer.byteLength(json, 'utf8') <= maxBytes) { best = mid; lo = mid + 1; }
199
+ else { hi = mid - 1; }
200
+ }
201
+ return envelope(sanitized.slice(0, best));
202
+ }
203
+
204
+ /** Bounded hint list for not-found responses. */
205
+ function boundedHint(hint, maxBytes = MAX_HINT_BYTES) {
206
+ const ids = [...new Set(hint.filter(Boolean).map(value => truncateUtf8(String(value), MAX_AGENT_FIELD_BYTES)))];
207
+ const envelope = available_agents => ({ ok: false, error_message: 'Agent not found', available_agents });
208
+ let result = [];
209
+ for (const id of ids) {
210
+ const candidate = [...result, id];
211
+ if (Buffer.byteLength(JSON.stringify(envelope(candidate))) > maxBytes) break;
212
+ result = candidate;
213
+ }
214
+ return envelope(result);
215
+ }
216
+
217
+ /** Select the terminal reply from authoritative output_refs and snapshots,
218
+ * ported from ksadk.a2a.result_text.select_terminal_reply. Prefers the last
219
+ * text artifact; falls back to the last agent message. */
220
+ function selectTerminalReply(outputRefs, snapshots, itemKinds) {
221
+ const selected = new Map(), ordered = { artifact: [], message: [] };
222
+ for (const ref of outputRefs || []) {
223
+ const key = `${ref.scope_id}:${ref.item_id}`;
224
+ if (!Object.hasOwn(itemKinds, key) || !Object.hasOwn(snapshots, key)) continue;
225
+ const kind = itemKinds[key];
226
+ const parts = snapshots[key];
227
+ if (!parts || !Object.hasOwn(ordered, kind)) continue;
228
+ if (!selected.has(key)) { selected.set(key, []); ordered[kind].push(key); }
229
+ if (ref.part_id == null) { for (const v of Object.values(parts)) if (typeof v === 'string' && v) selected.get(key).push(v); }
230
+ else if (Object.hasOwn(parts, String(ref.part_id))) {
231
+ const v = parts[String(ref.part_id)]; if (typeof v === 'string' && v) selected.get(key).push(v);
232
+ }
233
+ }
234
+ const candidates = ordered.artifact.length ? ordered.artifact : ordered.message;
235
+ if (!candidates.length) return '';
236
+ return selected.get(candidates[candidates.length - 1]).join('');
237
+ }
238
+
239
+ /** Same result semantics for execution and an independently verified old receipt. */
240
+ export function nativeA2aTerminalResult(observation, agent) {
241
+ invariant(TERMINAL_STATUSES.has(observation?.state), 'a2a_terminal_receipt_required');
242
+ const content = { ok: observation.state === 'completed', agent, task_id: observation.task_id,
243
+ state: observation.state, reply: observation.state === 'completed'
244
+ ? selectTerminalReply(observation.output_refs, observation.snapshots, observation.item_kinds) : '' };
245
+ return { status: content.ok ? 'ok' : 'error', content };
246
+ }
247
+
248
+ // ---- Identity guard --------------------------------------------------------
249
+
250
+ function createIdentityGuard(request, assertCurrent) {
251
+ const captureBody = () => {
252
+ const body = {};
253
+ for (const key of Object.keys(request)) {
254
+ if (key === 'signal') continue;
255
+ body[key] = request[key];
256
+ }
257
+ return canonical(body);
258
+ };
259
+ const before = captureBody();
260
+ const signal = request.signal; // frozen signal identity
261
+ return () => {
262
+ if (assertCurrent) {
263
+ const result = assertCurrent(request);
264
+ if (result !== true) {
265
+ if (result && typeof result.then === 'function') { result.then(() => {}, () => {}); }
266
+ throw lockFailure('a2a_lease_lost');
267
+ }
268
+ }
269
+ if (signal !== request.signal) throw lockFailure('a2a_signal_replaced');
270
+ if (before !== captureBody()) throw lockFailure('a2a_context_drift');
271
+ signal.throwIfAborted(); // use frozen signal, not request.signal
272
+ };
273
+ }
274
+
275
+ function validateStrictIdentity(request, definition) {
276
+ const errors = [];
277
+ const scope = request.scope;
278
+ if (!scope || typeof scope !== 'object' || !scope.tenantId || !scope.workspaceId || !scope.userId || !scope.agentId) errors.push('scope must have all four domains');
279
+ if (!request.sessionId || typeof request.sessionId !== 'string') errors.push('sessionId must be a non-empty string');
280
+ for (const f of ['runId', 'turnId', 'buildId', 'taskChainId', 'operationKey']) {
281
+ if (!request[f] || typeof request[f] !== 'string') errors.push(`${f} must be a non-empty string`);
282
+ }
283
+ if (request.callId == null && request.call?.callId == null) errors.push('callId must be present');
284
+ if (request.parentRunId != null && (typeof request.parentRunId !== 'string' || !request.parentRunId)) errors.push('parentRunId must be null or non-empty');
285
+ if (!Number.isSafeInteger(request.executionFence) || request.executionFence <= 0) errors.push('executionFence must be a positive integer');
286
+ if (!(request.signal instanceof AbortSignal)) errors.push('signal must be an AbortSignal instance');
287
+ if (request.definition == null) errors.push('request.definition is required');
288
+ else if (!matchesToolExecutionContract(request.definition, definition)) errors.push('request.definition must equal the locked formal execution contract');
289
+ return errors;
290
+ }
291
+
292
+ function validateDshIdentity(request, definition, owner) {
293
+ const errors = [];
294
+ for (const key of ['scope', 'runId', 'turnId', 'taskChainId', 'executionFence', 'parentRunId'])
295
+ if (Object.hasOwn(request, key)) errors.push('Native identity is not a DSH call');
296
+ if (request.agentId !== owner.agentId || request.agentRevision !== owner.agentRevision || request.buildId !== owner.buildId)
297
+ errors.push('DSH owner binding mismatch');
298
+ if (typeof request.operationKey !== 'string' || !request.operationKey.trim()
299
+ || typeof request.call?.callId !== 'string' || !request.call.callId.trim()) errors.push('DSH call identity required');
300
+ if (!(request.signal instanceof AbortSignal)) errors.push('signal must be an AbortSignal instance');
301
+ if (canonical(request.definition) !== canonical(definition)) errors.push('definition mismatch');
302
+ return errors;
303
+ }
304
+
305
+ // ---- Tool implementations --------------------------------------------------
306
+
307
+ async function listA2aAgents({ discovery, spaceId, signal, guard }) {
308
+ if (!spaceId) return { ok: false, error_message: 'A2A Space not configured' };
309
+ try { guard(); } catch (e) { if (signal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost before discovery' }; }
310
+ let agents;
311
+ try { agents = await abortable(signal, () => discovery.list({ spaceId, signal })); }
312
+ catch (error) { if (signal.aborted) throw error; return { ok: false, error_type: 'a2a_discovery_failed', error_message: 'Unable to list A2A agents' }; }
313
+ try { guard(); } catch (e) { if (signal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost after discovery' }; }
314
+ return sanitizeAgentList(agents, spaceId);
315
+ }
316
+
317
+ async function getA2aAgentCard({ discovery, spaceId, agent, signal, guard }) {
318
+ if (!spaceId) return { ok: false, error_message: 'A2A Space not configured' };
319
+ try { guard(); } catch (e) { if (signal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost before discovery' }; }
320
+ let agents;
321
+ try { agents = await abortable(signal, () => discovery.list({ spaceId, signal })); }
322
+ catch (error) { if (signal.aborted) throw error; return { ok: false, error_type: 'a2a_discovery_failed', error_message: 'Unable to list A2A agents' }; }
323
+ try { guard(); } catch (e) { if (signal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost after discovery' }; }
324
+ const { matched, hint } = matchAgent(agents, agent);
325
+ if (!matched) return boundedHint(hint);
326
+ return { ok: true, agent: sanitizeAgent(matched) };
327
+ }
328
+
329
+ async function callA2aAgent({ invocation, exposurePlan, agent, message, frozenLocator, frozenSignal, guard, request, effect, dsh }) {
330
+ if (!invocation || typeof invocation.resolve !== 'function' || typeof invocation.dispatch !== 'function') {
331
+ return { ok: false, error_type: 'A2A_MANAGED_INVOCATION_CONTEXT_REQUIRED', error_message: 'Managed A2A invocation context is required' };
332
+ }
333
+ if (!String(message || '').trim()) return { ok: false, error_message: 'message is required' };
334
+ // exposurePlan is REQUIRED and must fail closed.
335
+ if (!exposurePlan || typeof exposurePlan.assertGenericTargetAllowed !== 'function') {
336
+ return { ok: false, error_type: 'A2A_EXPOSURE_PLAN_REQUIRED', error_message: 'Exposure plan is required and must fail closed' };
337
+ }
338
+ try { guard(); } catch (e) { if (frozenSignal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost before resolve' }; }
339
+ let resolved;
340
+ try {
341
+ resolved = await abortable(frozenSignal, () => invocation.resolve({ agent, message, scope: request.scope, sessionId: request.sessionId, runId: request.runId, callId: request.call?.callId, operationKey: request.operationKey, signal: frozenSignal,
342
+ ...(dsh ? { dshRequest: request } : { nativeRequest: request }) }));
343
+ } catch (error) {
344
+ if (frozenSignal.aborted) throw error;
345
+ return { ok: false, error_type: 'A2A_INVOCATION_FAILED', error_message: 'Managed A2A invocation failed' };
346
+ }
347
+ // Seal the resolved spec/trigger immediately — plan/permission callbacks
348
+ // cannot mutate the target after resolution.
349
+ const sealedResolved = freezeJson(copy(resolved));
350
+ const sealedSpec = sealedResolved.spec;
351
+ const sealedTrigger = sealedResolved.trigger;
352
+ invariant(typeof sealedSpec?.target_agent_id === 'string' && sealedSpec.target_agent_id.trim(), 'a2a_target_required');
353
+ // The exposure plan validates that the resolved target is a generic allowed
354
+ // agent. assertGenericTargetAllowed must be SYNCHRONOUS: return undefined
355
+ // (void). Any non-undefined return (false, 0, thenable, etc.) is rejected.
356
+ try {
357
+ const planResult = exposurePlan.assertGenericTargetAllowed(sealedSpec.target_agent_id);
358
+ if (planResult !== undefined) {
359
+ if (planResult && typeof planResult.then === 'function') {
360
+ planResult.then(() => {}, () => {}); // observe to prevent unhandledRejection
361
+ }
362
+ return { ok: false, error_type: 'A2A_EXPOSURE_PLAN_INVALID_RETURN', error_message: 'Exposure plan must return undefined; non-undefined or thenable is rejected' };
363
+ }
364
+ } catch (error) { return { ok: false, error_type: 'A2A_TARGET_NOT_ALLOWED', error_message: 'The resolved target is not allowed by the exposure plan' }; }
365
+ try { guard(); } catch (e) { if (frozenSignal.aborted) throw e; return { ok: false, error_type: e.code, error_message: 'Lease lost before dispatch' }; }
366
+ // effectStarted is set ONLY immediately before calling dispatch.
367
+ // Pre-dispatch rejections return error:executed=false.
368
+ // The sealed spec/trigger is used for dispatch — the target cannot change.
369
+ try {
370
+ effect.agent = sealedSpec.target_agent_id;
371
+ effect.started = true; // shared with the outer final guard, before dispatch
372
+ const dispatchResult = await abortable(frozenSignal, () => invocation.dispatch({ request: sealedResolved, trigger: sealedTrigger, signal: frozenSignal }));
373
+ // Preserve a known remote task even if later result interpretation aborts.
374
+ effect.taskId = dispatchResult?.task_id ?? null;
375
+ // Guard after dispatch using frozen signal.
376
+ try { guard(); } catch (e) {
377
+ return { ok: false, error_type: 'A2A_INVOCATION_UNCERTAIN', error_message: 'Lease lost after dispatch; the remote call may have completed', agent: effect.agent, executed: true, task_id: effect.taskId, operation_locator: frozenLocator };
378
+ }
379
+ // Validate terminal status. running/unknown are NOT success.
380
+ const status = String(dispatchResult?.state || dispatchResult?.status || 'unknown').toLowerCase();
381
+ if (!TERMINAL_STATUSES.has(status)) {
382
+ return { ok: false, error_type: 'A2A_INVOCATION_UNCERTAIN', error_message: `Invocation is not terminal (state: ${status})`, agent: effect.agent, executed: true, task_id: effect.taskId, operation_locator: frozenLocator };
383
+ }
384
+ // Select reply from authoritative output_refs only.
385
+ const reply = status === 'completed'
386
+ ? selectTerminalReply(dispatchResult.output_refs, dispatchResult.snapshots || {}, dispatchResult.item_kinds || {})
387
+ : '';
388
+ return { ok: status === 'completed', agent: effect.agent, task_id: effect.taskId, state: status, reply };
389
+ } catch (error) {
390
+ if (frozenSignal.aborted && !effect.started) throw error;
391
+ // effectStarted — dispatch may have started. Uncertain with frozen locator.
392
+ return { ok: false, error_type: 'A2A_INVOCATION_UNCERTAIN', error_message: 'Dispatch failed; the remote call may have been sent', agent: effect.agent, executed: effect.started, task_id: effect.taskId, operation_locator: frozenLocator };
393
+ }
394
+ }
395
+
396
+ // ---- Factory ---------------------------------------------------------------
397
+
398
+ export function createNativeA2aToolPort(options = {}) { return createA2aToolPort(options, false); }
399
+ export function createDshA2aToolPort(options = {}) { return createA2aToolPort(options, true); }
400
+
401
+ function createA2aToolPort({
402
+ agentId, agentRevision, buildId = null, scope: boundScopeInput, sessionId, spaceId, discovery, invocation, exposurePlan,
403
+ authorize, assertCurrent, bindingDescription, receiptObservation = false,
404
+ } = {}, dsh) {
405
+ invariant(typeof sessionId === 'string' && sessionId, 'a2a_session_required');
406
+ invariant(typeof discovery?.list === 'function', 'a2a_discovery_required');
407
+ invariant(typeof authorize === 'function', 'a2a_authorize_required');
408
+ invariant(typeof assertCurrent === 'function', 'a2a_assert_current_required');
409
+ if (dsh) invariant(typeof agentId === 'string' && agentId.trim() && Number.isSafeInteger(agentRevision) && agentRevision > 0
410
+ && (buildId === null || typeof buildId === 'string' && buildId.trim()), 'a2a_dsh_owner_required');
411
+ else invariant(boundScopeInput && boundScopeInput.tenantId && boundScopeInput.workspaceId && boundScopeInput.userId && boundScopeInput.agentId, 'a2a_bound_scope_required');
412
+ const contract = dsh ? DSH_A2A_TOOLS_CONTRACT : NATIVE_A2A_TOOLS_CONTRACT;
413
+ // bindingDescription is a serializable object from the Host containing
414
+ // sourceRef, sourceDigest, spaceId, planDigest, transport, version.
415
+ // Required for a stable bindingDigest that detects plan/transport changes.
416
+ invariant(bindingDescription && typeof bindingDescription === 'object', 'a2a_binding_description_required');
417
+ invariant(typeof bindingDescription.sourceRef === 'string' && bindingDescription.sourceRef, 'a2a_source_ref_required');
418
+ invariant(typeof bindingDescription.sourceDigest === 'string' && bindingDescription.sourceDigest, 'a2a_source_digest_required');
419
+ invariant(typeof bindingDescription.planDigest === 'string' && bindingDescription.planDigest, 'a2a_plan_digest_required');
420
+ invariant(typeof bindingDescription.transport === 'string' && bindingDescription.transport, 'a2a_transport_required');
421
+ invariant(typeof bindingDescription.version === 'string' && bindingDescription.version, 'a2a_version_required');
422
+ const boundScope = dsh ? null : Object.freeze(copy(boundScopeInput));
423
+ invariant(!dsh || !receiptObservation, 'a2a_native_receipt_policy_not_dsh');
424
+ const definitions = nativeA2aToolDefinitions({ receiptObservation });
425
+ const formal = new Map(definitions.map(item => [item.name, item]));
426
+
427
+ async function execute(request) {
428
+ if (!request || typeof request !== 'object') return { status: 'error', content: { code: 'invalid_request', message: 'request object required' } };
429
+ if (request.sessionId !== sessionId) return { status: 'error', content: { code: 'a2a_session_mismatch' } };
430
+ if (!dsh && canonical(request.scope) !== canonical(boundScope)) return { status: 'error', content: { code: 'invalid_identity', message: 'request.scope must match the trusted bound scope' } };
431
+ const idErrors = dsh ? validateDshIdentity(request, formal.get(request.call?.name) || {}, { agentId, agentRevision, buildId })
432
+ : validateStrictIdentity(request, formal.get(request.call?.name) || {});
433
+ if (idErrors.length) return { status: 'error', content: { code: 'invalid_identity', message: idErrors.join('; ') } };
434
+ const call = request.call;
435
+ const definition = formal.get(call.name);
436
+ if (!definition) return { status: 'error', content: { code: 'unknown_tool', message: `unknown tool: ${call.name}` } };
437
+ const rejection = toolInputRejection(definition, call);
438
+ if (rejection) return { status: 'error', content: rejection };
439
+ request.signal.throwIfAborted();
440
+
441
+ // Freeze the locator at entry from the immutable bound scope — before any
442
+ // await. Callback tampering of the mutable request cannot pollute the
443
+ // uncertain receipt. The frozen signal is captured by createIdentityGuard
444
+ // and used for all abort checks (not the mutable request.signal).
445
+ const frozenLocator = Object.freeze(dsh ? { contract, agentId, agentRevision, buildId, sessionId,
446
+ operationKey: request.operationKey, callId: request.call.callId } : {
447
+ scope: boundScope, sessionId: request.sessionId, runId: request.runId,
448
+ turnId: request.turnId, buildId: request.buildId, parentRunId: request.parentRunId ?? null,
449
+ taskChainId: request.taskChainId, operationKey: request.operationKey,
450
+ executionFence: request.executionFence, callId: call.callId,
451
+ });
452
+ const frozenSignal = request.signal; // captured before any await
453
+ const guard = createIdentityGuard(request, assertCurrent);
454
+ let decision;
455
+ try { guard(); } catch (error) { if (frozenSignal.aborted) throw error; return { status: 'error', content: { code: error.code, executed: false } }; }
456
+ try { decision = await abortable(frozenSignal, () => authorize(request)); }
457
+ catch (error) { if (frozenSignal.aborted) throw error; return { status: 'error', content: { code: 'authorize_failed', message: 'authorization failed' } }; }
458
+ try { guard(); } catch (e) { if (frozenSignal.aborted) throw e; return { status: 'error', content: { code: e.code, executed: false } }; }
459
+ if (decision !== 'allow') {
460
+ if (decision?.status === 'approval_required' && typeof decision.interactionId === 'string') return decision;
461
+ return { status: 'error', content: { code: 'policy_denied', executed: false } };
462
+ }
463
+ const args = copy(call.arguments);
464
+ // Dispatch records effect state directly. Return content is not an
465
+ // authority for deciding whether an external call has already started.
466
+ const effect = { started: false, taskId: null, agent: args.agent };
467
+ const uncertain = content => ({ status: 'uncertain', content: {
468
+ ...content, ok: false, error_type: 'A2A_INVOCATION_UNCERTAIN',
469
+ agent: effect.agent, executed: true, task_id: effect.taskId, operation_locator: frozenLocator,
470
+ } });
471
+ try {
472
+ if (call.name === 'list_a2a_agents') {
473
+ const content = await listA2aAgents({ discovery, spaceId, signal: frozenSignal, guard });
474
+ try { guard(); } catch (e) { if (frozenSignal.aborted) throw e; return { status: 'error', content: { code: e.code, executed: false } }; }
475
+ return { status: content.ok ? 'ok' : 'error', content };
476
+ }
477
+ if (call.name === 'get_a2a_agent_card') {
478
+ const content = await getA2aAgentCard({ discovery, spaceId, agent: args.agent, signal: frozenSignal, guard });
479
+ try { guard(); } catch (e) { if (frozenSignal.aborted) throw e; return { status: 'error', content: { code: e.code, executed: false } }; }
480
+ return { status: content.ok ? 'ok' : 'error', content };
481
+ }
482
+ if (call.name === 'call_a2a_agent') {
483
+ const content = await callA2aAgent({ invocation, exposurePlan, agent: args.agent, message: args.message, frozenLocator, frozenSignal, guard, request, effect, dsh });
484
+ // Final guard after all operations — uses frozen signal.
485
+ try { guard(); } catch (e) {
486
+ if (frozenSignal.aborted && !effect.started) throw e;
487
+ // If the guard throws after an effect, return uncertain with frozen
488
+ // locator — never lose the effect by returning a bare error.
489
+ if (effect.started) return uncertain(content);
490
+ return { status: 'error', content: { ...content, ok: false, error_type: e.code, executed: false } };
491
+ }
492
+ if (content.error_type === 'A2A_INVOCATION_UNCERTAIN') return uncertain(content);
493
+ return { status: content.ok ? 'ok' : 'error', content };
494
+ }
495
+ } catch (error) {
496
+ if (frozenSignal.aborted && !effect.started) throw error;
497
+ if (effect.started) return uncertain({ error_message: 'Operation interrupted after dispatch; the remote call may have completed' });
498
+ return { status: 'error', content: { ok: false, error_type: error.code || 'A2AError', error_message: 'Operation failed' } };
499
+ }
500
+ return { status: 'error', content: { code: 'unknown_tool', message: `unknown tool: ${call.name}` } };
501
+ }
502
+
503
+ // Binding digest from the serializable binding description — no secrets.
504
+ const bindingDigest = createHash('sha256').update(canonical({
505
+ contract,
506
+ sourceRef: bindingDescription.sourceRef,
507
+ sourceDigest: bindingDescription.sourceDigest,
508
+ spaceId: bindingDescription.spaceId || spaceId || '',
509
+ planDigest: bindingDescription.planDigest,
510
+ transport: bindingDescription.transport,
511
+ version: bindingDescription.version,
512
+ ...(dsh ? { agentId, agentRevision, buildId } : { scope: boundScope }),
513
+ sessionId,
514
+ })).digest('hex');
515
+
516
+ return { definitions: definitions.map(item => copy(item)), execute, contract, bindingDigest };
517
+ }
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /** Content and lifecycle facts from committed task state; no control authority. */
4
+ export function nativeActivityStatus(record, { driving = false } = {}) {
5
+ if (!record) return null;
6
+ const { snapshot } = record;
7
+ const todos = snapshot.todos?.items ?? [], occurrences = new Map();
8
+ const items = todos.slice(0, 512).map(todo => {
9
+ const digest = createHash('sha256').update(todo.content).digest('hex');
10
+ const occurrence = occurrences.get(digest) ?? 0; occurrences.set(digest, occurrence + 1);
11
+ return { id: `todo:${digest}:${occurrence}`, content: todo.content, status: todo.status };
12
+ });
13
+ const observations = new Map();
14
+ for (const event of record.events ?? []) if (event.kind === 'background.commands_observed')
15
+ for (const command of event.payload.commands ?? []) observations.set(command.taskId, command);
16
+ const terminals = (snapshot.backgroundCommands ?? []).slice(-128).map(({ ref }) => {
17
+ const observed = observations.get(ref.taskId);
18
+ const intent = record.intents.find(row => row.operationKey === ref.operationKey);
19
+ const calls = intent?.payload.calls ?? [];
20
+ const call = calls.length === 1 ? calls[0] : null;
21
+ return { id: ref.taskId, runId: snapshot.runId,
22
+ title: call?.arguments.command?.slice(0, 512) || ref.taskId,
23
+ state: observed?.status ?? 'unknown', canCancel: false };
24
+ });
25
+ return { contract: 'agentkit.native-activity-status/v1', runId: snapshot.runId, snapshotRevision: snapshot.revision,
26
+ isRunning: driving, plan: todos.length ? { items,
27
+ completedCount: todos.filter(todo => todo.status === 'completed').length, totalCount: todos.length,
28
+ omittedItems: Math.max(0, todos.length - items.length) } : null,
29
+ terminals, omittedTerminals: Math.max(0, (snapshot.backgroundCommands ?? []).length - terminals.length) };
30
+ }