@armoriq/sdk-dev 0.6.8 → 0.7.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.
Files changed (99) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.js +1 -1
  4. package/dist/cli/commands/auth.d.ts +12 -0
  5. package/dist/cli/commands/auth.d.ts.map +1 -1
  6. package/dist/cli/commands/auth.js +460 -42
  7. package/dist/cli/commands/auth.js.map +1 -1
  8. package/dist/cli/index.js +0 -0
  9. package/dist/client.d.ts +8 -15
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +20 -18
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +0 -17
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -19
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -14
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/google_adk.d.ts +168 -6
  22. package/dist/integrations/google_adk.d.ts.map +1 -1
  23. package/dist/integrations/google_adk.js +797 -103
  24. package/dist/integrations/google_adk.js.map +1 -1
  25. package/dist/integrations/langchain.d.ts +51 -1
  26. package/dist/integrations/langchain.d.ts.map +1 -1
  27. package/dist/integrations/langchain.js +563 -30
  28. package/dist/integrations/langchain.js.map +1 -1
  29. package/dist/integrations/strands.d.ts +48 -0
  30. package/dist/integrations/strands.d.ts.map +1 -1
  31. package/dist/integrations/strands.js +441 -27
  32. package/dist/integrations/strands.js.map +1 -1
  33. package/dist/models.d.ts +2 -2
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/observability/content-capture.d.ts +75 -0
  36. package/dist/observability/content-capture.d.ts.map +1 -0
  37. package/dist/observability/content-capture.js +339 -0
  38. package/dist/observability/content-capture.js.map +1 -0
  39. package/dist/observability/index.d.ts +6 -7
  40. package/dist/observability/index.d.ts.map +1 -1
  41. package/dist/observability/index.js +18 -27
  42. package/dist/observability/index.js.map +1 -1
  43. package/dist/observability/otel-config.d.ts +47 -0
  44. package/dist/observability/otel-config.d.ts.map +1 -0
  45. package/dist/observability/otel-config.js +268 -0
  46. package/dist/observability/otel-config.js.map +1 -0
  47. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  48. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  49. package/dist/observability/otel-export-ceiling.js +264 -0
  50. package/dist/observability/otel-export-ceiling.js.map +1 -0
  51. package/dist/observability/otel-runtime.d.ts +103 -0
  52. package/dist/observability/otel-runtime.d.ts.map +1 -0
  53. package/dist/observability/otel-runtime.js +668 -0
  54. package/dist/observability/otel-runtime.js.map +1 -0
  55. package/dist/observability/otel-session.d.ts +168 -0
  56. package/dist/observability/otel-session.d.ts.map +1 -0
  57. package/dist/observability/otel-session.js +621 -0
  58. package/dist/observability/otel-session.js.map +1 -0
  59. package/dist/observability/otel-shutdown.d.ts +17 -0
  60. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  61. package/dist/observability/otel-shutdown.js +54 -0
  62. package/dist/observability/otel-shutdown.js.map +1 -0
  63. package/dist/observability/policy-lease.d.ts +22 -0
  64. package/dist/observability/policy-lease.d.ts.map +1 -0
  65. package/dist/observability/policy-lease.js +102 -0
  66. package/dist/observability/policy-lease.js.map +1 -0
  67. package/dist/plan_builder.d.ts +5 -4
  68. package/dist/plan_builder.d.ts.map +1 -1
  69. package/dist/plan_builder.js +14 -15
  70. package/dist/plan_builder.js.map +1 -1
  71. package/dist/session.d.ts +61 -93
  72. package/dist/session.d.ts.map +1 -1
  73. package/dist/session.js +388 -812
  74. package/dist/session.js.map +1 -1
  75. package/dist/token_usage.d.ts +11 -18
  76. package/dist/token_usage.d.ts.map +1 -1
  77. package/dist/token_usage.js +29 -94
  78. package/dist/token_usage.js.map +1 -1
  79. package/dist/tool_name.d.ts +18 -0
  80. package/dist/tool_name.d.ts.map +1 -0
  81. package/dist/tool_name.js +29 -0
  82. package/dist/tool_name.js.map +1 -0
  83. package/dist/tool_push.d.ts +28 -0
  84. package/dist/tool_push.d.ts.map +1 -0
  85. package/dist/tool_push.js +151 -0
  86. package/dist/tool_push.js.map +1 -0
  87. package/dist/tool_registry.d.ts +100 -0
  88. package/dist/tool_registry.d.ts.map +1 -0
  89. package/dist/tool_registry.js +440 -0
  90. package/dist/tool_registry.js.map +1 -0
  91. package/dist/tool_schema.d.ts +22 -0
  92. package/dist/tool_schema.d.ts.map +1 -0
  93. package/dist/tool_schema.js +163 -0
  94. package/dist/tool_schema.js.map +1 -0
  95. package/package.json +13 -7
  96. package/dist/integrations/microsoft_copilot.d.ts +0 -84
  97. package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
  98. package/dist/integrations/microsoft_copilot.js +0 -126
  99. package/dist/integrations/microsoft_copilot.js.map +0 -1
@@ -16,6 +16,7 @@
16
16
  * for await (const event of runner.runAsync(...)) { ... }
17
17
  * } finally {
18
18
  * scope.uninstall(rootAgent);
19
+ * await scope.close(); // ends the plan trace and flushes it
19
20
  * }
20
21
  *
21
22
  * The bundle installs three ADK lifecycle callbacks on the agent:
@@ -30,7 +31,27 @@
30
31
  */
31
32
  Object.defineProperty(exports, "__esModule", { value: true });
32
33
  exports.ArmorIQADKBundle = exports.ArmorIQADK = void 0;
34
+ const tool_push_1 = require("../tool_push");
35
+ const tool_registry_1 = require("../tool_registry");
33
36
  const client_1 = require("../client");
37
+ const tool_name_1 = require("../tool_name");
38
+ let warnedFunctionCallIdFallback = false;
39
+ function terminalStatusFromError(error) {
40
+ const value = error;
41
+ const name = String(value?.name ?? 'Error').toLowerCase();
42
+ const code = String(value?.code ?? '').toLowerCase();
43
+ const message = String(value?.message ?? '').toLowerCase();
44
+ if (name.includes('timeout') || code === 'etimedout' || message.includes('timed out'))
45
+ return 'timeout';
46
+ if (name === 'aborterror' || code === 'abort_err' || code === 'err_abort')
47
+ return 'cancelled';
48
+ if (code === 'econnreset' ||
49
+ code === 'err_stream_premature_close' ||
50
+ message.includes('disconnect') ||
51
+ message.includes('connection reset'))
52
+ return 'disconnected';
53
+ return 'error';
54
+ }
34
55
  /**
35
56
  * Process-wide ArmorIQ factory for ADK-style agents.
36
57
  */
@@ -45,6 +66,8 @@ class ArmorIQADK {
45
66
  // observes the same value, but no env or option toggles it.
46
67
  autoReanchor = true;
47
68
  trueReanchor;
69
+ approvalWaitSeconds;
70
+ approvalPollInterval;
48
71
  customParser;
49
72
  bootstrapData;
50
73
  explicitAgentName;
@@ -67,6 +90,10 @@ class ArmorIQADK {
67
90
  this.mode = opts.mode ?? 'sdk';
68
91
  this.llm = opts.llm ?? 'agent';
69
92
  this.trueReanchor = opts.trueReanchor ?? true;
93
+ this.approvalWaitSeconds =
94
+ opts.approvalWaitSeconds ??
95
+ Number(process.env.ARMORIQ_APPROVAL_WAIT_SECONDS ?? '300');
96
+ this.approvalPollInterval = opts.approvalPollInterval ?? 5;
70
97
  }
71
98
  async bootstrap() {
72
99
  if (!this.bootstrapData) {
@@ -99,17 +126,23 @@ class ArmorIQADK {
99
126
  async toolNameParser() {
100
127
  if (this.customParser)
101
128
  return this.customParser;
102
- const bootstrap = await this.bootstrap();
103
- const toolMap = bootstrap.toolMap ?? {};
129
+ let toolMap = {};
130
+ try {
131
+ toolMap = (await this.bootstrap()).toolMap ?? {};
132
+ }
133
+ catch {
134
+ // Bootstrap only enriches routing. Model-only ADK work remains usable;
135
+ // any later real tool still goes through fail-closed session enforcement.
136
+ console.warn('[armoriq] google-adk bootstrap unavailable; using safe tool-name fallback');
137
+ }
104
138
  const defaultMcp = this.defaultMcpName;
105
139
  return (toolName) => {
106
140
  const mcp = toolMap[toolName];
107
141
  if (mcp)
108
142
  return { mcp, action: toolName };
109
- if (toolName.includes('__')) {
110
- const idx = toolName.indexOf('__');
111
- return { mcp: toolName.slice(0, idx), action: toolName.slice(idx + 2) };
112
- }
143
+ const split = (0, tool_name_1.splitPrefixedToolName)(toolName);
144
+ if (split)
145
+ return split;
113
146
  if (defaultMcp)
114
147
  return { mcp: defaultMcp, action: toolName };
115
148
  return { mcp: 'unknown', action: toolName };
@@ -119,7 +152,6 @@ class ArmorIQADK {
119
152
  this.client.invalidateUser(userEmail);
120
153
  }
121
154
  async forUser(userEmail, opts) {
122
- await this.bootstrap();
123
155
  const scope = this.client.forUser(userEmail);
124
156
  const parser = await this.toolNameParser();
125
157
  return new ArmorIQADKBundle({
@@ -133,6 +165,61 @@ class ArmorIQADK {
133
165
  }
134
166
  }
135
167
  exports.ArmorIQADK = ArmorIQADK;
168
+ /**
169
+ * Owner-keyed subscriber registry for watchForLateParent(): more than one
170
+ * bundle can independently resolve the SAME shared owner as their
171
+ * `invocationOwner` — e.g. two sibling agents nested inside one inner
172
+ * SequentialAgent, each with its own bundle installed. Object.defineProperty
173
+ * only supports a single descriptor per property, so each bundle calling
174
+ * watchForLateParent() with its own private accessor would let the second
175
+ * bundle's install() silently clobber the first bundle's getter/setter — and
176
+ * that first bundle's finalizer would then never migrate when the shared
177
+ * owner is later composed into an outer workflow (it would keep closing at
178
+ * the inner boundary). Keep exactly one accessor per owner and fan its
179
+ * setter out to every subscribed bundle instead of just whichever bundle
180
+ * happened to call watchForLateParent() last.
181
+ */
182
+ const lateParentSubscribers = new WeakMap();
183
+ const runAsyncRegistrations = new WeakMap();
184
+ /**
185
+ * The four ADK lifecycle callback slots this SDK writes.
186
+ */
187
+ const ADK_CALLBACK_SLOTS = [
188
+ 'beforeModelCallback',
189
+ 'afterModelCallback',
190
+ 'beforeToolCallback',
191
+ 'afterToolCallback',
192
+ ];
193
+ const callbackRegistrations = new WeakMap();
194
+ /**
195
+ * Adopt as the new original any slot the application has taken over since our
196
+ * last install, so the next bundle chains behind the application's current
197
+ * callback instead of overwriting it with a stale snapshot.
198
+ */
199
+ function recaptureReplacedSlots(agent, registration) {
200
+ const installed = registration.installed;
201
+ if (!installed)
202
+ return;
203
+ for (const slot of ADK_CALLBACK_SLOTS) {
204
+ if (agent[slot] === installed[slot] || agent[slot] === registration.original[slot])
205
+ continue;
206
+ registration.original[slot] = agent[slot];
207
+ console.warn(`[armoriq] google-adk ${slot} changed after ArmorIQ installed; chaining behind the ` +
208
+ 'current callback. Replacing a slot that still holds ArmorIQ callbacks keeps them ' +
209
+ 'reachable through your own wrapper.');
210
+ }
211
+ }
212
+ /** Returns false if any slot was replaced by the application and left alone. */
213
+ function restoreCallbackSlots(agent, registration) {
214
+ let unwoundEverySlot = true;
215
+ for (const slot of ADK_CALLBACK_SLOTS) {
216
+ if (agent[slot] === registration.installed?.[slot])
217
+ agent[slot] = registration.original[slot];
218
+ else
219
+ unwoundEverySlot = false;
220
+ }
221
+ return unwoundEverySlot;
222
+ }
136
223
  /**
137
224
  * Per-request ADK bundle — installs/uninstalls lifecycle callbacks on
138
225
  * one agent and binds them to one user's session.
@@ -145,11 +232,52 @@ class ArmorIQADKBundle {
145
232
  scope;
146
233
  parser;
147
234
  planMinted = false;
148
- blockedTools = new Set();
149
- blockedActions = new Map();
235
+ pendingPlanCapture;
236
+ /** Terminal enforcement state keyed by ADK's native functionCallId. */
237
+ blockedCalls = new Map();
238
+ /**
239
+ * Legacy duck-typed callers may omit functionCallId. Keep a FIFO fallback
240
+ * for those callers; real ADK 0.6.1 always supplies the native id.
241
+ */
242
+ anonymousBlockedCalls = new Map();
243
+ nextAnonymousCallOrdinal = 0;
244
+ modelSpans = new Map();
245
+ toolSpans = new Map();
246
+ /** Model output is scoped by ADK's native invocation key. A scalar leaks a
247
+ * concurrent invocation's result into another span/root. */
248
+ modelOutputs = new Map();
249
+ /**
250
+ * Root/session output per invocation. Defaults to the raw model output
251
+ * (mirrors modelOutputs) but the PAP rejection branch overwrites the entry
252
+ * — never modelOutputs — with the safe response ADK actually returns, so
253
+ * root/session telemetry exports what the caller really got back instead
254
+ * of the blocked content.
255
+ */
256
+ rootOutputs = new Map();
257
+ toolOrdinals = new Map();
258
+ nextToolOrdinal = 0;
259
+ nextInvocationOrdinal = 0;
260
+ closed = false;
261
+ closePromise;
150
262
  onEvent;
151
263
  agent;
152
- saved = {};
264
+ /**
265
+ * The agent whose runAsync actually got the close-on-completion wrapper.
266
+ * Usually the installed agent itself; when the installed agent is a child
267
+ * of a composed workflow (see install()) this is the outermost ancestor,
268
+ * so uninstall() must restore runAsync on THIS object, not on the
269
+ * installed agent.
270
+ */
271
+ runAsyncOwner;
272
+ /**
273
+ * Set while `runAsyncOwner` is only a provisional owner (the installed
274
+ * agent had no `parentAgent` yet at install() time). Holds the agent whose
275
+ * `parentAgent` property we replaced with an accessor so we notice if ADK
276
+ * composes it into a parent *after* install() — see watchForLateParent().
277
+ */
278
+ parentWatchTarget;
279
+ /** The agent whose callback slots this bundle last wrote. */
280
+ callbackAgent;
153
281
  constructor(args) {
154
282
  this.factory = args.factory;
155
283
  this.scope = args.scope;
@@ -187,10 +315,84 @@ class ArmorIQADKBundle {
187
315
  }
188
316
  return this.session;
189
317
  }
318
+ async capturePlan(toolCalls) {
319
+ if (!toolCalls.length)
320
+ return;
321
+ const session = this.ensureSession();
322
+ const pendingPlanCapture = { session, promise: session.startPlan(toolCalls, this.goal) };
323
+ this.pendingPlanCapture = pendingPlanCapture;
324
+ try {
325
+ await pendingPlanCapture.promise;
326
+ if (this.session === session)
327
+ this.planMinted = true;
328
+ }
329
+ finally {
330
+ if (this.pendingPlanCapture === pendingPlanCapture)
331
+ this.pendingPlanCapture = undefined;
332
+ }
333
+ }
334
+ /** End and ship the request-owned plan session without closing the shared client. */
335
+ async close(status = 'ok') {
336
+ if (this.closePromise)
337
+ return this.closePromise;
338
+ this.closePromise = this.closeInternal(status);
339
+ return this.closePromise;
340
+ }
341
+ async closeInternal(status) {
342
+ this.closed = true;
343
+ const session = this.session;
344
+ const pendingPlanCapture = this.pendingPlanCapture;
345
+ // A bundle normally owns one ADK invocation. If a host overlaps requests
346
+ // on one bundle, omit the ambiguous root output rather than export the
347
+ // wrong request's response; individual model spans remain complete.
348
+ const rootOutput = this.rootOutputs.size === 1
349
+ ? this.rootOutputs.values().next().value
350
+ : undefined;
351
+ this.session = undefined;
352
+ this.planMinted = false;
353
+ this.blockedCalls.clear();
354
+ this.anonymousBlockedCalls.clear();
355
+ const error = status === 'ok' ? undefined : new Error('terminal lifecycle');
356
+ const unfinishedToolOutcome = status === 'error' ? 'error' : status === 'timeout' ? 'timeout' :
357
+ status === 'disconnected' ? 'disconnected' : 'cancelled';
358
+ await Promise.all([
359
+ ...[...this.modelSpans.values()].filter(Boolean).map((span) => session?.otelSession?.endModel(span, {}, error, status)),
360
+ ...[...this.toolSpans.values()].filter(Boolean).map((span) => session?.otelSession?.endTool(span, { outcome: unfinishedToolOutcome, error })),
361
+ ]);
362
+ this.toolSpans.clear();
363
+ this.modelSpans.clear();
364
+ if (session && pendingPlanCapture && pendingPlanCapture.session === session) {
365
+ try {
366
+ await pendingPlanCapture.promise;
367
+ }
368
+ catch {
369
+ // afterModel already logs capture failures; teardown remains best-effort.
370
+ }
371
+ }
372
+ if (session) {
373
+ if (rootOutput === undefined)
374
+ await session.close(status);
375
+ else
376
+ await session.close(status, undefined, { output: rootOutput });
377
+ }
378
+ this.modelOutputs.clear();
379
+ this.rootOutputs.clear();
380
+ // Nothing calls uninstall() on a real ADK server, so without this the
381
+ // owner retains every closed bundle. The agent's callback slots stay
382
+ // installed on purpose; see releaseFromOwners().
383
+ this.releaseFromOwners();
384
+ }
190
385
  async afterModel(...adkArgs) {
386
+ if (this.closed)
387
+ return null;
388
+ const modelKey = this.modelKey(adkArgs[0]);
389
+ let modelError;
390
+ let modelValues = {};
191
391
  try {
192
- if (this.planMinted)
193
- return null;
392
+ // Re-mint every model turn (mirrors strands / langchain). A multi-turn run
393
+ // chooses new tools on each turn; minting only the first turn's tools left
394
+ // every later step undeclared. startPlan is idempotent per plan hash, so
395
+ // re-minting an unchanged tool set is cheap.
194
396
  // ADK callback signature drifted between versions. Older releases
195
397
  // passed (callbackContext, llmResponse); current ADK (0.6+) passes
196
398
  // a single { context, response } params object. Accept both.
@@ -205,10 +407,29 @@ class ArmorIQADKBundle {
205
407
  llmResponse = adkArgs[1] ?? adkArgs[0];
206
408
  }
207
409
  const parts = llmResponse?.content?.parts ?? [];
410
+ this.toolOrdinals.clear();
411
+ this.nextToolOrdinal = 0;
412
+ modelValues = {
413
+ ...(llmResponse?.usageMetadata?.promptTokenCount !== undefined
414
+ ? { 'gen_ai.usage.input_tokens': llmResponse.usageMetadata.promptTokenCount }
415
+ : {}),
416
+ ...(llmResponse?.usageMetadata?.candidatesTokenCount !== undefined
417
+ ? { 'gen_ai.usage.output_tokens': llmResponse.usageMetadata.candidatesTokenCount }
418
+ : {}),
419
+ };
420
+ const modelOutput = llmResponse?.content ?? llmResponse?.output;
421
+ if (modelOutput !== undefined) {
422
+ this.modelOutputs.set(modelKey, modelOutput);
423
+ // Defaults the root/export output to the raw model response; the PAP
424
+ // branch below overwrites this entry if it replaces what ADK returns.
425
+ this.rootOutputs.set(modelKey, modelOutput);
426
+ }
208
427
  const toolCalls = [];
209
428
  for (const p of parts) {
210
429
  const fc = p?.functionCall ?? p?.function_call;
211
430
  if (fc?.name) {
431
+ if (typeof fc.id === 'string' && fc.id)
432
+ this.toolOrdinals.set(fc.id, this.nextToolOrdinal++);
212
433
  toolCalls.push({ name: fc.name, args: fc.args ? { ...fc.args } : {} });
213
434
  }
214
435
  }
@@ -254,12 +475,17 @@ class ArmorIQADKBundle {
254
475
  console.warn(`[armoriq] PAP rejected plan user=${this.userEmail} ` +
255
476
  `violations=[${violations}] ` +
256
477
  `predicate_fails=${refineResult.predicateFails.length}`);
257
- return buildRejectionResponse(violations, {
478
+ const rejection = buildRejectionResponse(violations, {
258
479
  decision: refineResult.decision,
259
480
  violations: refineResult.violations,
260
481
  predicateFails: refineResult.predicateFails,
261
482
  intentId: refineResult.intentId,
262
483
  });
484
+ // ADK actually hands this response back to the caller, not the
485
+ // original (blocked) model output captured above — root/session
486
+ // telemetry must reflect what was really returned.
487
+ this.rootOutputs.set(modelKey, rejection.content);
488
+ return rejection;
263
489
  }
264
490
  console.info(`[armoriq] PAP refine ok user=${this.userEmail} intent=${refineResult.intentId}`);
265
491
  }
@@ -267,29 +493,157 @@ class ArmorIQADKBundle {
267
493
  const msg = refineErr.message;
268
494
  console.warn(`[armoriq] PAP refine failed (fail-${client.papFailMode}): ${msg}`);
269
495
  if (client.papFailMode === 'closed') {
270
- return buildRejectionResponse(`pap_unavailable: ${msg}`, {
496
+ const rejection = buildRejectionResponse(`pap_unavailable: ${msg}`, {
271
497
  decision: 'rejected',
272
498
  violations: ['pap_unavailable'],
273
499
  predicateFails: [],
274
500
  });
501
+ this.rootOutputs.set(modelKey, rejection.content);
502
+ return rejection;
275
503
  }
276
504
  // fail-open: fall through, continue with original tool calls.
277
505
  }
278
506
  }
279
507
  // ────────────────────────────────────────────────────────────────
280
- await this.ensureSession().startPlan(toolCalls, this.goal);
281
- this.planMinted = true;
508
+ await this.capturePlan(toolCalls);
282
509
  console.info(`[armoriq] plan minted user=${this.userEmail} tools=${toolCalls.length}`);
283
510
  }
284
511
  catch (exc) {
512
+ modelError = exc;
285
513
  console.warn(`[armoriq] afterModelCallback failed: ${exc.message}`);
286
514
  }
515
+ finally {
516
+ const span = this.modelSpans.get(modelKey);
517
+ this.modelSpans.delete(modelKey);
518
+ const modelOutput = this.modelOutputs.get(modelKey);
519
+ await this.session?.otelSession?.endModel(span ?? { span: null, name: 'gen_ai.chat' }, modelValues, modelError, undefined, modelOutput === undefined ? undefined : { output: modelOutput });
520
+ }
287
521
  return null;
288
522
  }
289
- async beforeTool(tool, args, _toolContext) {
523
+ /** ADK resolves toolsets on every model step, so a changed tool set is visible here. */
524
+ refreshDeclarations() {
525
+ if (!this.agent)
526
+ return;
527
+ try {
528
+ const d = (0, tool_registry_1.declarationsFromAdkAgent)(this.agent);
529
+ (0, tool_registry_1.registerForClient)(this.factory.client, d.tools, d.servers);
530
+ }
531
+ catch {
532
+ /* inventory problems never stop an agent */
533
+ }
534
+ (0, tool_push_1.noteModelCall)(this.factory.client);
535
+ }
536
+ async beforeModel(...adkArgs) {
537
+ if (this.closed)
538
+ return undefined;
539
+ this.refreshDeclarations();
540
+ const params = adkArgs[0] ?? {};
541
+ const model = params?.request?.model ?? params?.model ?? this.factory.llm;
542
+ const input = params?.request?.contents ?? params?.request?.content ?? params?.input ?? this.goal;
543
+ const key = this.modelKey(params);
544
+ if (!this.modelSpans.has(key)) {
545
+ const otel = this.ensureSession().otelSession;
546
+ if (typeof otel?.beginRoot === 'function')
547
+ await otel.beginRoot({ input: this.goal });
548
+ const span = typeof otel?.beginModel === 'function'
549
+ ? await otel.beginModel(String(model), { input })
550
+ : undefined;
551
+ if (span)
552
+ this.modelSpans.set(key, span);
553
+ }
554
+ return undefined;
555
+ }
556
+ modelKey(params) {
557
+ const context = params?.context ?? params?.callbackContext;
558
+ const invocationId = context?.invocationId ?? context?.sessionId;
559
+ return typeof invocationId === 'string' && invocationId
560
+ ? `adk:${invocationId}`
561
+ : `anonymous:${++this.nextInvocationOrdinal}`;
562
+ }
563
+ toolOrdinal(toolContext) {
564
+ const functionCallId = toolContext?.functionCallId;
565
+ return typeof functionCallId === 'string' ? this.toolOrdinals.get(functionCallId) : undefined;
566
+ }
567
+ frameworkMcpOperation(toolName, itemOrdinal, toolCallId) {
568
+ return {
569
+ category: 'mcp',
570
+ name: 'mcp.execute',
571
+ toolType: 'mcp',
572
+ toolName,
573
+ callId: toolCallId,
574
+ mcpServer: this.parser(toolName).mcp,
575
+ planItemOrdinal: itemOrdinal,
576
+ };
577
+ }
578
+ anonymousCallFingerprint(toolName, args) {
579
+ try {
580
+ return `${toolName}:${JSON.stringify(args ?? {})}`;
581
+ }
582
+ catch {
583
+ return `${toolName}:[unserializable]`;
584
+ }
585
+ }
586
+ /**
587
+ * The fallback is only for legacy duck-typed callers. Keep the diagnostic
588
+ * fixed and once per process so it cannot leak tool data or flood host logs.
589
+ */
590
+ warnFunctionCallIdFallback() {
591
+ if (warnedFunctionCallIdFallback)
592
+ return;
593
+ warnedFunctionCallIdFallback = true;
594
+ console.warn('[armoriq] google-adk functionCallId missing; using bounded compatibility fallback correlation');
595
+ }
596
+ callKeyForBefore(toolName, args, toolContext) {
597
+ const functionCallId = toolContext?.functionCallId;
598
+ if (typeof functionCallId === 'string' && functionCallId)
599
+ return `adk:${functionCallId}`;
600
+ this.warnFunctionCallIdFallback();
601
+ const fingerprint = this.anonymousCallFingerprint(toolName, args);
602
+ const key = `anonymous:${fingerprint}:${++this.nextAnonymousCallOrdinal}`;
603
+ const queue = this.anonymousBlockedCalls.get(fingerprint) ?? [];
604
+ queue.push(key);
605
+ this.anonymousBlockedCalls.set(fingerprint, queue);
606
+ return key;
607
+ }
608
+ callKeyForAfter(toolName, args, toolContext) {
609
+ const functionCallId = toolContext?.functionCallId;
610
+ if (typeof functionCallId === 'string' && functionCallId)
611
+ return `adk:${functionCallId}`;
612
+ this.warnFunctionCallIdFallback();
613
+ const fingerprint = this.anonymousCallFingerprint(toolName, args);
614
+ const queue = this.anonymousBlockedCalls.get(fingerprint);
615
+ const key = queue?.shift();
616
+ if (queue && queue.length === 0)
617
+ this.anonymousBlockedCalls.delete(fingerprint);
618
+ return key;
619
+ }
620
+ rememberBlockedCall(toolName, args, toolContext, action) {
621
+ this.blockedCalls.set(this.callKeyForBefore(toolName, args, toolContext), action);
622
+ }
623
+ async beforeTool(tool, args, toolContext) {
624
+ if (this.closed) {
625
+ return { error: 'ArmorIQ request is closed', armoriq_enforcement: { blocked: true, action: 'block' } };
626
+ }
290
627
  const toolName = tool?.name ?? String(tool);
628
+ const session = this.ensureSession();
629
+ const itemOrdinal = this.toolOrdinal(toolContext);
630
+ const policySpan = await session.otelSession?.beginPolicy({
631
+ toolName, itemOrdinal, toolCallId: toolContext?.functionCallId,
632
+ arguments: args ?? {},
633
+ });
291
634
  try {
292
- const decision = await this.ensureSession().check(toolName, args ?? {}, this.userEmail);
635
+ const decision = await session.check(toolName, args ?? {}, this.userEmail, { emitOtel: false });
636
+ const policy = decision;
637
+ await session.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, {
638
+ decision: decision.allowed ? 'allow' : decision.action === 'hold' ? 'hold' : 'block',
639
+ policyName: decision.matchedPolicy,
640
+ ...(policy.policyId ? { policyId: policy.policyId } : {}),
641
+ policyVersion: policy.policyVersion,
642
+ policySource: policy.policySource,
643
+ policyReasonCode: decision.reason,
644
+ defaultAction: policy.defaultAction,
645
+ matchedRuleId: policy.matchedRuleId,
646
+ });
293
647
  if (!decision.allowed) {
294
648
  const policy = decision.matchedPolicy ? ` (policy: ${decision.matchedPolicy})` : '';
295
649
  if (decision.action === 'hold') {
@@ -300,70 +654,45 @@ class ArmorIQADKBundle {
300
654
  reason: decision.reason,
301
655
  matchedPolicy: decision.matchedPolicy,
302
656
  });
303
- // 30-min default, 3s → 15s exponential, matches PY hold-retry.
304
- const timeoutMs = 30 * 60 * 1000;
305
- const deadline = Date.now() + timeoutMs;
306
- let pollIntervalMs = 3000;
307
- let attempt = 0;
308
- let approved = false;
309
- let finalDecision = decision;
310
- while (Date.now() < deadline) {
311
- await new Promise((r) => setTimeout(r, pollIntervalMs));
312
- pollIntervalMs = Math.min(pollIntervalMs * 1.5, 15000);
313
- attempt += 1;
314
- const retry = await this.ensureSession().check(toolName, args ?? {}, this.userEmail);
315
- finalDecision = retry;
316
- if (retry.allowed) {
317
- console.info(`[armoriq] APPROVED ${toolName} attempt ${attempt}`);
318
- this.emit('approved', { tool: toolName, delegationId: decision.delegationId });
319
- approved = true;
320
- break;
321
- }
322
- if (retry.action !== 'hold') {
323
- console.info(`[armoriq] REJECTED ${toolName} action=${retry.action}`);
324
- this.emit('rejected', {
325
- tool: toolName,
326
- delegationId: retry.delegationId ?? decision.delegationId,
327
- action: retry.action,
328
- reason: retry.reason,
329
- });
330
- break;
331
- }
332
- }
333
- if (approved)
334
- return null;
335
- const finalPolicy = finalDecision.matchedPolicy
336
- ? ` (policy: ${finalDecision.matchedPolicy})`
337
- : '';
338
- this.blockedTools.add(toolName);
339
- this.blockedActions.set(toolName, finalDecision.action);
340
- if (finalDecision.action === 'hold') {
341
- this.emit('timeout', {
342
- tool: toolName,
343
- delegationId: finalDecision.delegationId,
344
- reason: finalDecision.reason,
345
- });
346
- return {
347
- error: `Approval timed out${finalPolicy}. Reason: ${finalDecision.reason ?? 'policy-hold'}.`,
348
- armoriq_enforcement: {
349
- blocked: true,
350
- action: 'hold',
351
- reason: finalDecision.reason,
352
- matched_policy: finalDecision.matchedPolicy,
353
- tool: toolName,
354
- delegation_id: finalDecision.delegationId,
355
- },
356
- };
657
+ // Shared delegation wait -- same helper, option and default as the
658
+ // strands / langchain integrations. Unlike the old hand-rolled check()
659
+ // poll this marks the delegation executed on approval, instead of
660
+ // leaving it open in the backend.
661
+ const outcome = await this.ensureSession().awaitApproval(decision.delegationId, {
662
+ timeout: this.factory.approvalWaitSeconds,
663
+ interval: this.factory.approvalPollInterval,
664
+ userEmail: this.userEmail,
665
+ });
666
+ if (outcome === 'approved') {
667
+ console.info(`[armoriq] APPROVED ${toolName} user=${this.userEmail}`);
668
+ this.emit('approved', { tool: toolName, delegationId: decision.delegationId });
669
+ this.toolSpans.set(this.callKeyForBefore(toolName, args, toolContext), await session.otelSession?.beginTool({
670
+ toolName,
671
+ itemOrdinal,
672
+ toolCallId: toolContext?.functionCallId,
673
+ arguments: args,
674
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolContext?.functionCallId),
675
+ }));
676
+ return null; // approved -> the tool runs
357
677
  }
678
+ console.info(`[armoriq] hold ${outcome} for ${toolName} user=${this.userEmail}`);
679
+ this.rememberBlockedCall(toolName, args, toolContext, 'hold');
680
+ this.emit(outcome, {
681
+ tool: toolName,
682
+ delegationId: decision.delegationId,
683
+ reason: decision.reason,
684
+ matchedPolicy: decision.matchedPolicy,
685
+ });
358
686
  return {
359
- error: `This action is not permitted${finalPolicy}. Reason: ${finalDecision.reason ?? 'policy-blocked'}.`,
687
+ error: `${outcome === 'timeout' ? 'Approval timed out' : 'This action was not approved'}${policy}. Reason: ${decision.reason ?? 'policy-hold'}.`,
360
688
  armoriq_enforcement: {
361
689
  blocked: true,
362
- action: finalDecision.action,
363
- reason: finalDecision.reason,
364
- matched_policy: finalDecision.matchedPolicy,
690
+ action: 'hold',
691
+ outcome,
692
+ reason: decision.reason,
693
+ matched_policy: decision.matchedPolicy,
365
694
  tool: toolName,
366
- delegation_id: finalDecision.delegationId,
695
+ delegation_id: decision.delegationId,
367
696
  },
368
697
  };
369
698
  }
@@ -373,8 +702,7 @@ class ArmorIQADKBundle {
373
702
  reason: decision.reason,
374
703
  matchedPolicy: decision.matchedPolicy,
375
704
  });
376
- this.blockedTools.add(toolName);
377
- this.blockedActions.set(toolName, decision.action);
705
+ this.rememberBlockedCall(toolName, args, toolContext, decision.action);
378
706
  console.info(`[armoriq] BLOCKED ${toolName} user=${this.userEmail} action=${decision.action} reason=${decision.reason}`);
379
707
  return {
380
708
  error: `This action is not permitted by your organization's policy${policy}. Reason: ${decision.reason ?? 'policy-blocked'}.`,
@@ -388,16 +716,25 @@ class ArmorIQADKBundle {
388
716
  },
389
717
  };
390
718
  }
719
+ this.toolSpans.set(this.callKeyForBefore(toolName, args, toolContext), await session.otelSession?.beginTool({
720
+ toolName,
721
+ itemOrdinal,
722
+ toolCallId: toolContext?.functionCallId,
723
+ arguments: args,
724
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolContext?.functionCallId),
725
+ }));
391
726
  }
392
727
  catch (exc) {
728
+ await session.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, {
729
+ error: exc, policyReasonCode: 'enforcement_error',
730
+ });
393
731
  // Fail closed: ADK reads a null return as "proceed", so an enforcement
394
732
  // error has to come back as a payload or the tool runs unchecked. Matches
395
733
  // the strands/langchain hooks, which also refuse the tool on error.
396
734
  const msg = exc.message ?? String(exc);
397
735
  this.emit('error', { tool: toolName, error: msg });
398
736
  console.error(`[armoriq] beforeToolCallback failed (fail-closed): ${msg}`);
399
- this.blockedTools.add(toolName);
400
- this.blockedActions.set(toolName, 'block');
737
+ this.rememberBlockedCall(toolName, args, toolContext, 'block');
401
738
  return {
402
739
  error: `ArmorIQ enforcement error (fail-closed): ${msg}`,
403
740
  armoriq_enforcement: {
@@ -412,41 +749,121 @@ class ArmorIQADKBundle {
412
749
  }
413
750
  return null;
414
751
  }
415
- async afterTool(tool, args, _toolContext, toolResponse) {
752
+ async afterTool(tool, args, toolContext, toolResponse) {
753
+ if (this.closed)
754
+ return null;
416
755
  const toolName = tool?.name ?? String(tool);
417
756
  try {
418
- if (this.blockedTools.has(toolName)) {
419
- const action = this.blockedActions.get(toolName) ?? 'block';
420
- this.blockedActions.delete(toolName);
421
- this.blockedTools.delete(toolName);
757
+ const callKey = this.callKeyForAfter(toolName, args, toolContext);
758
+ const action = callKey ? this.blockedCalls.get(callKey) : undefined;
759
+ if (action) {
760
+ this.blockedCalls.delete(callKey);
422
761
  if (action !== 'hold') {
423
762
  await this.ensureSession().report(toolName, args ?? {}, toolResponse, {
424
763
  status: 'failed',
425
764
  errorMessage: 'Blocked by policy',
765
+ operation: this.frameworkMcpOperation(toolName, this.toolOrdinal(toolContext), toolContext?.functionCallId),
426
766
  });
427
767
  }
428
768
  return null;
429
769
  }
430
- await this.ensureSession().report(toolName, args ?? {}, toolResponse);
770
+ const toolSpan = callKey ? this.toolSpans.get(callKey) : undefined;
771
+ if (callKey)
772
+ this.toolSpans.delete(callKey);
773
+ await this.ensureSession().otelSession?.endTool(toolSpan ?? { span: null, name: 'armoriq.tool' }, {
774
+ outcome: toolResponse?.error ? 'error' : 'success',
775
+ error: toolResponse?.error,
776
+ result: toolResponse,
777
+ });
778
+ await this.ensureSession().report(toolName, args ?? {}, toolResponse, {
779
+ emitOtel: false,
780
+ operation: this.frameworkMcpOperation(toolName, this.toolOrdinal(toolContext), toolContext?.functionCallId),
781
+ });
431
782
  }
432
783
  catch (exc) {
433
784
  console.warn(`[armoriq] afterToolCallback failed: ${exc.message}`);
434
785
  }
435
786
  return null;
436
787
  }
437
- /**
438
- * Attach the three callbacks to the ADK-style agent. Save originals
439
- * for uninstall().
440
- */
788
+ /** Attach this bundle to an ADK-style agent. */
441
789
  install(agent) {
442
790
  this.agent = agent;
443
- this.saved = {
444
- afterModelCallback: agent.afterModelCallback,
445
- beforeToolCallback: agent.beforeToolCallback,
446
- afterToolCallback: agent.afterToolCallback,
447
- };
448
- agent.afterModelCallback = (...args) => this.afterModel(...args);
449
- agent.beforeToolCallback = (...args) => {
791
+ (0, tool_registry_1.registerForClient)(this.factory.client, ...(() => {
792
+ const d = (0, tool_registry_1.declarationsFromAdkAgent)(agent);
793
+ return [d.tools, d.servers];
794
+ })());
795
+ this.installCallbacks(agent);
796
+ // Bind automatic finalization to the true invocation boundary, not to
797
+ // "this installed agent's own iterator finished". ADK composes agents
798
+ // (SequentialAgent, ParallelAgent, ...) by running each child's runAsync
799
+ // to completion before the parent advances to the next one, so wrapping
800
+ // the installed agent directly would close the whole request — and stop
801
+ // telemetry for every later sibling — the moment the FIRST child
802
+ // finishes. Walk ADK's public `parentAgent` link to the outermost
803
+ // ancestor and bind the close-on-completion wrapper there instead; the
804
+ // model/tool callbacks above stay on the installed agent, since that is
805
+ // where ADK actually invokes them.
806
+ const owner = ArmorIQADKBundle.invocationOwner(agent);
807
+ this.bindFinalizer(owner);
808
+ if (!owner.parentAgent) {
809
+ // The *resolved owner* has no `parentAgent` yet — watch IT, not
810
+ // necessarily `agent`. That distinction matters when `agent` was
811
+ // already composed into an intermediate workflow before install():
812
+ // invocationOwner() then returns that intermediate ancestor (say,
813
+ // an inner SequentialAgent), which itself may still be composed into
814
+ // an outer workflow later. Comparing `owner === agent` would miss
815
+ // this case entirely — the intermediate ancestor is a distinct
816
+ // object from `agent`, so no watcher would ever get attached to it,
817
+ // and the bundle would close the moment the intermediate finishes
818
+ // instead of when the true outermost invocation completes. ADK sets
819
+ // a later parent with a plain `subAgent.parentAgent = this`
820
+ // (base_agent.js) strictly before the parent's own runAsync is ever
821
+ // invoked, so watch for that assignment on `owner` and move the
822
+ // finalizer to the real outermost owner once it happens.
823
+ ArmorIQADKBundle.watchForLateParent(owner, this);
824
+ }
825
+ return this;
826
+ }
827
+ /**
828
+ * Chained in front of the agent's pre-ArmorIQ callbacks, not in front of
829
+ * whatever is in the slot now. See `callbackRegistrations`. runAsync is
830
+ * wrapped separately, on the invocation owner rather than on the agent.
831
+ */
832
+ installCallbacks(agent) {
833
+ // Installing on a different agent must release the previous one first, or
834
+ // that agent keeps this bundle as its current owner and its slots keep our
835
+ // callbacks for good. bindFinalizer() releases its old owner for the same
836
+ // reason when a bundle migrates.
837
+ if (this.callbackAgent && this.callbackAgent !== agent)
838
+ this.releaseCallbacks(this.callbackAgent);
839
+ let registration = callbackRegistrations.get(agent);
840
+ // ADK gives each lifecycle callback a single slot, so a second live bundle
841
+ // on one agent cannot be dispatched to. Replacing the slots under a running
842
+ // request would route its remaining tool calls through the new request's
843
+ // policy and session, enforcing one user's call under another user's
844
+ // policy. Refuse instead of doing that silently. Sequential reuse is fine:
845
+ // a finished request's bundle is closed, and uninstall() clears `current`.
846
+ if (registration?.current && registration.current !== this && !registration.current.closed) {
847
+ throw new Error('ArmorIQADKBundle: this agent already has a live bundle installed. ADK gives each ' +
848
+ 'lifecycle callback one slot, so installing a second bundle would enforce the first ' +
849
+ "request's tool calls under this request's policy and session. Give each concurrent " +
850
+ 'request its own agent, or close the first bundle before installing the next.');
851
+ }
852
+ if (!registration) {
853
+ const original = {};
854
+ for (const slot of ADK_CALLBACK_SLOTS)
855
+ original[slot] = agent[slot];
856
+ registration = { original };
857
+ callbackRegistrations.set(agent, registration);
858
+ }
859
+ else {
860
+ recaptureReplacedSlots(agent, registration);
861
+ }
862
+ registration.current = this;
863
+ this.callbackAgent = agent;
864
+ const ourBeforeModel = (...args) => this.beforeModel(...args);
865
+ const ourAfterModel = (...args) => this.afterModel(...args);
866
+ const ourBeforeTool = (...args) => {
450
867
  const a = args[0];
451
868
  // Require `tool` specifically — anything else means we're in legacy
452
869
  // positional mode and a is the BaseTool itself.
@@ -455,7 +872,7 @@ class ArmorIQADKBundle {
455
872
  }
456
873
  return this.beforeTool(a, args[1], args[2]);
457
874
  };
458
- agent.afterToolCallback = (...args) => {
875
+ const ourAfterTool = (...args) => {
459
876
  const a = args[0];
460
877
  // Require `tool` specifically — anything else means we're in legacy
461
878
  // positional mode and a is the BaseTool itself.
@@ -464,15 +881,292 @@ class ArmorIQADKBundle {
464
881
  }
465
882
  return this.afterTool(a, args[1], args[2], args[3]);
466
883
  };
467
- return this;
884
+ // ADK accepts a single callback or an ordered list. Preserve callbacks the
885
+ // application installed first instead of silently replacing them. ADK
886
+ // stops the list only when a callback returns a replacement payload, so
887
+ // ArmorIQ still fail-closes before any later app callback/tool body runs.
888
+ const chain = (callback, ours) => [
889
+ ours,
890
+ ...(callback ? (Array.isArray(callback) ? callback : [callback]) : []),
891
+ ];
892
+ const ours = {
893
+ beforeModelCallback: ourBeforeModel,
894
+ afterModelCallback: ourAfterModel,
895
+ beforeToolCallback: ourBeforeTool,
896
+ afterToolCallback: ourAfterTool,
897
+ };
898
+ const installed = {};
899
+ for (const slot of ADK_CALLBACK_SLOTS) {
900
+ installed[slot] = chain(registration.original[slot], ours[slot]);
901
+ agent[slot] = installed[slot];
902
+ }
903
+ registration.installed = installed;
904
+ }
905
+ /**
906
+ * Subscribe this bundle to the close-on-completion wrapper for `owner`,
907
+ * installing that wrapper if this is the first bundle to claim the owner.
908
+ *
909
+ * There is exactly one wrapper per owner (see `runAsyncRegistrations`), so
910
+ * a second bundle resolving the same owner joins the existing wrapper's
911
+ * subscriber set rather than layering a second wrapper on top of the first.
912
+ * That keeps the true pre-wrap `runAsync` recoverable regardless of the
913
+ * order bundles later unsubscribe in, and means no inert wrapper can be
914
+ * left behind to retain a torn-down bundle.
915
+ */
916
+ bindFinalizer(owner) {
917
+ // Migrating to a new owner (handleLateParent) must release the old one,
918
+ // or the previous owner keeps this bundle alive in its subscriber set and
919
+ // would finalize it at the wrong — inner — invocation boundary.
920
+ if (this.runAsyncOwner && this.runAsyncOwner !== owner)
921
+ this.unsubscribeRunAsync();
922
+ this.runAsyncOwner = owner;
923
+ if (!owner.runAsync)
924
+ return;
925
+ const existing = runAsyncRegistrations.get(owner);
926
+ if (existing) {
927
+ existing.subscribers.add(this);
928
+ return;
929
+ }
930
+ const original = owner.runAsync;
931
+ const subscribers = new Set([this]);
932
+ const finalizeAll = async (status) => {
933
+ // Snapshot before the first await: closing one bundle can mutate the
934
+ // live set (its own uninstall, or an application close handler tearing
935
+ // down a sibling), and iterating a set being mutated mid-flight would
936
+ // skip subscribers. Each bundle checks its OWN closed state — sharing
937
+ // one `closed` reading across bundles would let the first bundle's
938
+ // close suppress the rest.
939
+ for (const subscriber of [...subscribers]) {
940
+ if (!subscriber.closed)
941
+ await subscriber.close(status);
942
+ }
943
+ };
944
+ const wrapped = function wrappedRunAsync(...args) {
945
+ const source = original.apply(this, args);
946
+ return (async function* () {
947
+ let completed = false;
948
+ try {
949
+ for await (const event of source)
950
+ yield event;
951
+ completed = true;
952
+ }
953
+ catch (error) {
954
+ await finalizeAll(terminalStatusFromError(error));
955
+ throw error;
956
+ }
957
+ finally {
958
+ await finalizeAll(completed ? 'ok' : 'cancelled');
959
+ }
960
+ })();
961
+ };
962
+ owner.runAsync = wrapped;
963
+ runAsyncRegistrations.set(owner, { original, wrapper: wrapped, subscribers });
964
+ }
965
+ /**
966
+ * Drop this bundle from its owner's wrapper subscriber set. The shared
967
+ * wrapper stays in place while any sibling bundle still needs it; only when
968
+ * the set drains does the owner get its TRUE pre-wrap `runAsync` back — the
969
+ * one recorded at registration time, never a snapshot of whatever happened
970
+ * to be installed when this particular bundle bound. Uninstall order is
971
+ * therefore irrelevant, and no wrapper survives an owner's last subscriber.
972
+ */
973
+ unsubscribeRunAsync() {
974
+ const owner = this.runAsyncOwner;
975
+ if (!owner)
976
+ return;
977
+ this.runAsyncOwner = undefined;
978
+ const registration = runAsyncRegistrations.get(owner);
979
+ if (!registration)
980
+ return;
981
+ registration.subscribers.delete(this);
982
+ if (registration.subscribers.size > 0)
983
+ return;
984
+ // Something outside this module wrapped `runAsync` after us; unwinding to
985
+ // `original` would silently drop that wrapper. Leave the registration in
986
+ // place instead — our (now subscriber-less, so inert) wrapper is still in
987
+ // the chain, and a future bindFinalizer() re-subscribes to it rather than
988
+ // stacking another layer.
989
+ if (owner.runAsync !== registration.wrapper)
990
+ return;
991
+ owner.runAsync = registration.original;
992
+ runAsyncRegistrations.delete(owner);
993
+ }
994
+ /**
995
+ * Release this bundle from both owner-keyed structures, so neither can be
996
+ * left holding it because only the other was released.
997
+ *
998
+ * The agent-keyed callback registry is deliberately NOT released here: it
999
+ * must survive close so a late tool call still meets `beforeTool`'s
1000
+ * fail-closed refusal. Only uninstall() releases it.
1001
+ */
1002
+ releaseFromOwners() {
1003
+ this.unsubscribeRunAsync();
1004
+ if (this.parentWatchTarget)
1005
+ this.restoreParentWatcher(this.parentWatchTarget);
1006
+ }
1007
+ /**
1008
+ * Put the agent's four callback slots back the way they were before ArmorIQ
1009
+ * touched them, but only while this bundle still occupies them.
1010
+ *
1011
+ * A later bundle that installed over this one owns the slots now, so
1012
+ * unwinding here would tear out live enforcement; that bundle's own release
1013
+ * restores the same recorded originals when its turn comes, which is why
1014
+ * uninstall order cannot matter. Each slot is compared against exactly what
1015
+ * we wrote, so a callback the application replaced afterwards is left alone
1016
+ * (the same guard `unsubscribeRunAsync()` applies to `runAsync`).
1017
+ *
1018
+ * Deliberately NOT called from closeInternal(). A closed bundle's callbacks
1019
+ * stay installed so a tool call arriving after the request ended still meets
1020
+ * `beforeTool`'s fail-closed refusal instead of reaching the application's
1021
+ * unguarded callbacks. That leaves at most the one current bundle reachable
1022
+ * from the agent, which the next install() replaces.
1023
+ */
1024
+ releaseCallbacks(agent) {
1025
+ const a = agent ?? this.callbackAgent;
1026
+ if (!a)
1027
+ return;
1028
+ const registration = callbackRegistrations.get(a);
1029
+ if (registration?.current !== this)
1030
+ return;
1031
+ const unwoundEverySlot = restoreCallbackSlots(a, registration);
1032
+ if (this.callbackAgent === a)
1033
+ this.callbackAgent = undefined;
1034
+ registration.current = undefined;
1035
+ // A slot we could not unwind still holds our callback inside the
1036
+ // application's wrapper. Dropping the record would let the next install()
1037
+ // capture that as "original" and chain a dead bundle back in.
1038
+ if (unwoundEverySlot)
1039
+ callbackRegistrations.delete(a);
1040
+ }
1041
+ /**
1042
+ * Replace `agent.parentAgent` with an accessor so we notice the moment ADK
1043
+ * (or application code) assigns a real parent to it — which happens
1044
+ * strictly before that parent's own runAsync can be invoked, since ADK
1045
+ * always finishes composing a tree before running it. When that happens,
1046
+ * re-bind the finalizer to the *new* outermost owner instead, so an
1047
+ * install()-before-composition call ends up wrapping the same object
1048
+ * install()-after-composition would have.
1049
+ *
1050
+ * Static, and keyed by `owner` (via `lateParentSubscribers`) rather than by
1051
+ * `bundle`, because more than one bundle can resolve the same owner (see
1052
+ * the registry doc comment above) — at most one accessor is ever installed
1053
+ * per owner, and its setter fans out to every subscribed bundle.
1054
+ */
1055
+ static watchForLateParent(owner, bundle) {
1056
+ bundle.parentWatchTarget = owner;
1057
+ const existing = lateParentSubscribers.get(owner);
1058
+ if (existing) {
1059
+ existing.add(bundle);
1060
+ return;
1061
+ }
1062
+ const subscribers = new Set([bundle]);
1063
+ lateParentSubscribers.set(owner, subscribers);
1064
+ let backing = owner.parentAgent;
1065
+ Object.defineProperty(owner, 'parentAgent', {
1066
+ configurable: true,
1067
+ enumerable: true,
1068
+ get: () => backing,
1069
+ set: (value) => {
1070
+ backing = value;
1071
+ if (!value)
1072
+ return;
1073
+ // ADK's own setParentAgentForSubAgents throws if parentAgent is
1074
+ // already set, so a real parent assignment fires this at most once
1075
+ // per owner. Restore a plain data property now that the late-parent
1076
+ // transition is resolved, then notify every subscriber so each one
1077
+ // independently migrates its own finalizer.
1078
+ lateParentSubscribers.delete(owner);
1079
+ delete owner.parentAgent;
1080
+ owner.parentAgent = value;
1081
+ for (const subscriber of subscribers)
1082
+ subscriber.handleLateParent(owner, value);
1083
+ },
1084
+ });
1085
+ }
1086
+ handleLateParent(agent, parent) {
1087
+ // A closed bundle must never rejoin any owner's registry. Normally
1088
+ // releaseFromOwners() has already dropped this bundle out of `agent`'s
1089
+ // lateParentSubscribers set by the time a real late parent arrives, but
1090
+ // closeInternal() sets `closed` synchronously and then awaits several
1091
+ // times before it reaches releaseFromOwners() — a late parent CAN land
1092
+ // on `agent` during that window, while this (already-closed) bundle is
1093
+ // still subscribed. Without this guard, bindFinalizer() below would
1094
+ // subscribe a dead bundle onto the new owner's runAsync registry, where
1095
+ // finalizeAll() skips closed subscribers forever — stranding both the
1096
+ // bundle and the new owner's wrapper permanently (issue this fixes).
1097
+ if (this.closed)
1098
+ return;
1099
+ if (this.parentWatchTarget === agent)
1100
+ this.parentWatchTarget = undefined;
1101
+ // The provisional finalizer was bound to `agent` (owner === agent, or a
1102
+ // shared intermediate owner, at the time this bundle last called
1103
+ // bindFinalizer). bindFinalizer() below unsubscribes this bundle from
1104
+ // that old owner before subscribing it to the new one, so the migration
1105
+ // is complete in both directions: the inner boundary can no longer
1106
+ // finalize us, and `agent.runAsync` is restored to its true original
1107
+ // only once the last sibling bundle has migrated off it too.
1108
+ const owner = ArmorIQADKBundle.invocationOwner(parent);
1109
+ this.bindFinalizer(owner);
1110
+ if (!owner.parentAgent) {
1111
+ // Same generalization as install(): `parent` itself may already have
1112
+ // been composed further up the tree by the time this late-parent
1113
+ // assignment fires, so invocationOwner(parent) can resolve to a
1114
+ // further ancestor. The resolved `owner` has no parent of its own yet
1115
+ // — it could still be composed into a grandparent later — so watch
1116
+ // `owner` itself (not necessarily `parent`) and keep watching.
1117
+ ArmorIQADKBundle.watchForLateParent(owner, this);
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Stop watching `agent` for a late parent assignment on this bundle's
1122
+ * behalf — used by uninstall() when the late parent never arrived. Removes
1123
+ * only this bundle from the shared subscriber set; the accessor is only
1124
+ * torn down (restored to a plain property) once no bundle is left
1125
+ * watching it, so unwinding one bundle never disturbs another bundle still
1126
+ * watching the same owner.
1127
+ */
1128
+ restoreParentWatcher(agent) {
1129
+ if (this.parentWatchTarget !== agent)
1130
+ return;
1131
+ this.parentWatchTarget = undefined;
1132
+ const subscribers = lateParentSubscribers.get(agent);
1133
+ if (!subscribers)
1134
+ return;
1135
+ subscribers.delete(this);
1136
+ if (subscribers.size > 0)
1137
+ return;
1138
+ lateParentSubscribers.delete(agent);
1139
+ const current = agent.parentAgent;
1140
+ delete agent.parentAgent;
1141
+ agent.parentAgent = current;
1142
+ }
1143
+ /**
1144
+ * Walk ADK's public `parentAgent` link to the outermost agent that owns
1145
+ * this invocation. An agent installed directly on a root (the common case,
1146
+ * and every existing call site) resolves to itself, so behavior there is
1147
+ * unchanged.
1148
+ */
1149
+ static invocationOwner(agent) {
1150
+ let owner = agent;
1151
+ const seen = new Set([owner]);
1152
+ while (owner.parentAgent && !seen.has(owner.parentAgent)) {
1153
+ owner = owner.parentAgent;
1154
+ seen.add(owner);
1155
+ }
1156
+ return owner;
468
1157
  }
469
1158
  uninstall(agent) {
470
1159
  const a = agent ?? this.agent;
471
1160
  if (!a)
472
1161
  return;
473
- a.afterModelCallback = this.saved.afterModelCallback;
474
- a.beforeToolCallback = this.saved.beforeToolCallback;
475
- a.afterToolCallback = this.saved.afterToolCallback;
1162
+ this.releaseCallbacks(a);
1163
+ // runAsync may have been wrapped on an ancestor of `a` (see install()),
1164
+ // and the late-parent watcher may be installed on a different owner
1165
+ // still. A sibling bundle can share either structure, so releasing this
1166
+ // bundle from both is what stops it from being finalized or migrated
1167
+ // later; each structure is only unwound once no bundle needs it (see
1168
+ // releaseFromOwners()).
1169
+ this.releaseFromOwners();
476
1170
  }
477
1171
  }
478
1172
  exports.ArmorIQADKBundle = ArmorIQADKBundle;