@armoriq/sdk-dev 0.6.10 → 0.8.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.
- package/README.md +168 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.d.ts.map +1 -1
- package/dist/_version.js +1 -1
- package/dist/_version.js.map +1 -1
- package/dist/cli/commands/auth.d.ts +12 -0
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +522 -49
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/client.d.ts +8 -15
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -18
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +0 -17
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -19
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -14
- package/dist/index.js.map +1 -1
- package/dist/integrations/google_adk.d.ts +155 -7
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +727 -46
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +48 -2
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +528 -33
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +65 -1
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +456 -36
- package/dist/integrations/strands.js.map +1 -1
- package/dist/models.d.ts +2 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/observability/content-capture.d.ts +103 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +423 -0
- package/dist/observability/content-capture.js.map +1 -0
- package/dist/observability/index.d.ts +6 -7
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +18 -27
- package/dist/observability/index.js.map +1 -1
- package/dist/observability/otel-config.d.ts +47 -0
- package/dist/observability/otel-config.d.ts.map +1 -0
- package/dist/observability/otel-config.js +268 -0
- package/dist/observability/otel-config.js.map +1 -0
- package/dist/observability/otel-export-ceiling.d.ts +96 -0
- package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
- package/dist/observability/otel-export-ceiling.js +271 -0
- package/dist/observability/otel-export-ceiling.js.map +1 -0
- package/dist/observability/otel-runtime.d.ts +103 -0
- package/dist/observability/otel-runtime.d.ts.map +1 -0
- package/dist/observability/otel-runtime.js +680 -0
- package/dist/observability/otel-runtime.js.map +1 -0
- package/dist/observability/otel-session.d.ts +168 -0
- package/dist/observability/otel-session.d.ts.map +1 -0
- package/dist/observability/otel-session.js +630 -0
- package/dist/observability/otel-session.js.map +1 -0
- package/dist/observability/otel-shutdown.d.ts +17 -0
- package/dist/observability/otel-shutdown.d.ts.map +1 -0
- package/dist/observability/otel-shutdown.js +54 -0
- package/dist/observability/otel-shutdown.js.map +1 -0
- package/dist/observability/policy-lease.d.ts +22 -0
- package/dist/observability/policy-lease.d.ts.map +1 -0
- package/dist/observability/policy-lease.js +102 -0
- package/dist/observability/policy-lease.js.map +1 -0
- package/dist/plan_builder.d.ts +5 -4
- package/dist/plan_builder.d.ts.map +1 -1
- package/dist/plan_builder.js +14 -15
- package/dist/plan_builder.js.map +1 -1
- package/dist/session.d.ts +61 -93
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +388 -804
- package/dist/session.js.map +1 -1
- package/dist/token_usage.d.ts +11 -18
- package/dist/token_usage.d.ts.map +1 -1
- package/dist/token_usage.js +29 -94
- package/dist/token_usage.js.map +1 -1
- package/dist/tool_name.d.ts +18 -0
- package/dist/tool_name.d.ts.map +1 -0
- package/dist/tool_name.js +29 -0
- package/dist/tool_name.js.map +1 -0
- package/dist/tool_push.d.ts +28 -0
- package/dist/tool_push.d.ts.map +1 -0
- package/dist/tool_push.js +151 -0
- package/dist/tool_push.js.map +1 -0
- package/dist/tool_registry.d.ts +100 -0
- package/dist/tool_registry.d.ts.map +1 -0
- package/dist/tool_registry.js +440 -0
- package/dist/tool_registry.js.map +1 -0
- package/dist/tool_schema.d.ts +22 -0
- package/dist/tool_schema.d.ts.map +1 -0
- package/dist/tool_schema.js +163 -0
- package/dist/tool_schema.js.map +1 -0
- package/package.json +12 -7
|
@@ -31,7 +31,27 @@
|
|
|
31
31
|
*/
|
|
32
32
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
33
|
exports.ArmorIQADKBundle = exports.ArmorIQADK = void 0;
|
|
34
|
+
const tool_push_1 = require("../tool_push");
|
|
35
|
+
const tool_registry_1 = require("../tool_registry");
|
|
34
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
|
+
}
|
|
35
55
|
/**
|
|
36
56
|
* Process-wide ArmorIQ factory for ADK-style agents.
|
|
37
57
|
*/
|
|
@@ -106,17 +126,23 @@ class ArmorIQADK {
|
|
|
106
126
|
async toolNameParser() {
|
|
107
127
|
if (this.customParser)
|
|
108
128
|
return this.customParser;
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
|
111
138
|
const defaultMcp = this.defaultMcpName;
|
|
112
139
|
return (toolName) => {
|
|
113
140
|
const mcp = toolMap[toolName];
|
|
114
141
|
if (mcp)
|
|
115
142
|
return { mcp, action: toolName };
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
}
|
|
143
|
+
const split = (0, tool_name_1.splitPrefixedToolName)(toolName);
|
|
144
|
+
if (split)
|
|
145
|
+
return split;
|
|
120
146
|
if (defaultMcp)
|
|
121
147
|
return { mcp: defaultMcp, action: toolName };
|
|
122
148
|
return { mcp: 'unknown', action: toolName };
|
|
@@ -126,7 +152,6 @@ class ArmorIQADK {
|
|
|
126
152
|
this.client.invalidateUser(userEmail);
|
|
127
153
|
}
|
|
128
154
|
async forUser(userEmail, opts) {
|
|
129
|
-
await this.bootstrap();
|
|
130
155
|
const scope = this.client.forUser(userEmail);
|
|
131
156
|
const parser = await this.toolNameParser();
|
|
132
157
|
return new ArmorIQADKBundle({
|
|
@@ -140,6 +165,61 @@ class ArmorIQADK {
|
|
|
140
165
|
}
|
|
141
166
|
}
|
|
142
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
|
+
}
|
|
143
223
|
/**
|
|
144
224
|
* Per-request ADK bundle — installs/uninstalls lifecycle callbacks on
|
|
145
225
|
* one agent and binds them to one user's session.
|
|
@@ -153,11 +233,51 @@ class ArmorIQADKBundle {
|
|
|
153
233
|
parser;
|
|
154
234
|
planMinted = false;
|
|
155
235
|
pendingPlanCapture;
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
158
262
|
onEvent;
|
|
159
263
|
agent;
|
|
160
|
-
|
|
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;
|
|
161
281
|
constructor(args) {
|
|
162
282
|
this.factory = args.factory;
|
|
163
283
|
this.scope = args.scope;
|
|
@@ -213,12 +333,34 @@ class ArmorIQADKBundle {
|
|
|
213
333
|
}
|
|
214
334
|
/** End and ship the request-owned plan session without closing the shared client. */
|
|
215
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;
|
|
216
343
|
const session = this.session;
|
|
217
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;
|
|
218
351
|
this.session = undefined;
|
|
219
352
|
this.planMinted = false;
|
|
220
|
-
this.
|
|
221
|
-
this.
|
|
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();
|
|
222
364
|
if (session && pendingPlanCapture && pendingPlanCapture.session === session) {
|
|
223
365
|
try {
|
|
224
366
|
await pendingPlanCapture.promise;
|
|
@@ -227,10 +369,25 @@ class ArmorIQADKBundle {
|
|
|
227
369
|
// afterModel already logs capture failures; teardown remains best-effort.
|
|
228
370
|
}
|
|
229
371
|
}
|
|
230
|
-
if (session)
|
|
231
|
-
|
|
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();
|
|
232
384
|
}
|
|
233
385
|
async afterModel(...adkArgs) {
|
|
386
|
+
if (this.closed)
|
|
387
|
+
return null;
|
|
388
|
+
const modelKey = this.modelKey(adkArgs[0]);
|
|
389
|
+
let modelError;
|
|
390
|
+
let modelValues = {};
|
|
234
391
|
try {
|
|
235
392
|
// Re-mint every model turn (mirrors strands / langchain). A multi-turn run
|
|
236
393
|
// chooses new tools on each turn; minting only the first turn's tools left
|
|
@@ -250,10 +407,29 @@ class ArmorIQADKBundle {
|
|
|
250
407
|
llmResponse = adkArgs[1] ?? adkArgs[0];
|
|
251
408
|
}
|
|
252
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
|
+
}
|
|
253
427
|
const toolCalls = [];
|
|
254
428
|
for (const p of parts) {
|
|
255
429
|
const fc = p?.functionCall ?? p?.function_call;
|
|
256
430
|
if (fc?.name) {
|
|
431
|
+
if (typeof fc.id === 'string' && fc.id)
|
|
432
|
+
this.toolOrdinals.set(fc.id, this.nextToolOrdinal++);
|
|
257
433
|
toolCalls.push({ name: fc.name, args: fc.args ? { ...fc.args } : {} });
|
|
258
434
|
}
|
|
259
435
|
}
|
|
@@ -299,12 +475,17 @@ class ArmorIQADKBundle {
|
|
|
299
475
|
console.warn(`[armoriq] PAP rejected plan user=${this.userEmail} ` +
|
|
300
476
|
`violations=[${violations}] ` +
|
|
301
477
|
`predicate_fails=${refineResult.predicateFails.length}`);
|
|
302
|
-
|
|
478
|
+
const rejection = buildRejectionResponse(violations, {
|
|
303
479
|
decision: refineResult.decision,
|
|
304
480
|
violations: refineResult.violations,
|
|
305
481
|
predicateFails: refineResult.predicateFails,
|
|
306
482
|
intentId: refineResult.intentId,
|
|
307
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;
|
|
308
489
|
}
|
|
309
490
|
console.info(`[armoriq] PAP refine ok user=${this.userEmail} intent=${refineResult.intentId}`);
|
|
310
491
|
}
|
|
@@ -312,11 +493,13 @@ class ArmorIQADKBundle {
|
|
|
312
493
|
const msg = refineErr.message;
|
|
313
494
|
console.warn(`[armoriq] PAP refine failed (fail-${client.papFailMode}): ${msg}`);
|
|
314
495
|
if (client.papFailMode === 'closed') {
|
|
315
|
-
|
|
496
|
+
const rejection = buildRejectionResponse(`pap_unavailable: ${msg}`, {
|
|
316
497
|
decision: 'rejected',
|
|
317
498
|
violations: ['pap_unavailable'],
|
|
318
499
|
predicateFails: [],
|
|
319
500
|
});
|
|
501
|
+
this.rootOutputs.set(modelKey, rejection.content);
|
|
502
|
+
return rejection;
|
|
320
503
|
}
|
|
321
504
|
// fail-open: fall through, continue with original tool calls.
|
|
322
505
|
}
|
|
@@ -326,14 +509,141 @@ class ArmorIQADKBundle {
|
|
|
326
509
|
console.info(`[armoriq] plan minted user=${this.userEmail} tools=${toolCalls.length}`);
|
|
327
510
|
}
|
|
328
511
|
catch (exc) {
|
|
512
|
+
modelError = exc;
|
|
329
513
|
console.warn(`[armoriq] afterModelCallback failed: ${exc.message}`);
|
|
330
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
|
+
}
|
|
331
521
|
return null;
|
|
332
522
|
}
|
|
333
|
-
|
|
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
|
+
}
|
|
334
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
|
+
});
|
|
335
634
|
try {
|
|
336
|
-
const decision = await
|
|
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
|
+
});
|
|
337
647
|
if (!decision.allowed) {
|
|
338
648
|
const policy = decision.matchedPolicy ? ` (policy: ${decision.matchedPolicy})` : '';
|
|
339
649
|
if (decision.action === 'hold') {
|
|
@@ -356,11 +666,17 @@ class ArmorIQADKBundle {
|
|
|
356
666
|
if (outcome === 'approved') {
|
|
357
667
|
console.info(`[armoriq] APPROVED ${toolName} user=${this.userEmail}`);
|
|
358
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
|
+
}));
|
|
359
676
|
return null; // approved -> the tool runs
|
|
360
677
|
}
|
|
361
678
|
console.info(`[armoriq] hold ${outcome} for ${toolName} user=${this.userEmail}`);
|
|
362
|
-
this.
|
|
363
|
-
this.blockedActions.set(toolName, 'hold');
|
|
679
|
+
this.rememberBlockedCall(toolName, args, toolContext, 'hold');
|
|
364
680
|
this.emit(outcome, {
|
|
365
681
|
tool: toolName,
|
|
366
682
|
delegationId: decision.delegationId,
|
|
@@ -386,8 +702,7 @@ class ArmorIQADKBundle {
|
|
|
386
702
|
reason: decision.reason,
|
|
387
703
|
matchedPolicy: decision.matchedPolicy,
|
|
388
704
|
});
|
|
389
|
-
this.
|
|
390
|
-
this.blockedActions.set(toolName, decision.action);
|
|
705
|
+
this.rememberBlockedCall(toolName, args, toolContext, decision.action);
|
|
391
706
|
console.info(`[armoriq] BLOCKED ${toolName} user=${this.userEmail} action=${decision.action} reason=${decision.reason}`);
|
|
392
707
|
return {
|
|
393
708
|
error: `This action is not permitted by your organization's policy${policy}. Reason: ${decision.reason ?? 'policy-blocked'}.`,
|
|
@@ -401,16 +716,25 @@ class ArmorIQADKBundle {
|
|
|
401
716
|
},
|
|
402
717
|
};
|
|
403
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
|
+
}));
|
|
404
726
|
}
|
|
405
727
|
catch (exc) {
|
|
728
|
+
await session.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, {
|
|
729
|
+
error: exc, policyReasonCode: 'enforcement_error',
|
|
730
|
+
});
|
|
406
731
|
// Fail closed: ADK reads a null return as "proceed", so an enforcement
|
|
407
732
|
// error has to come back as a payload or the tool runs unchecked. Matches
|
|
408
733
|
// the strands/langchain hooks, which also refuse the tool on error.
|
|
409
734
|
const msg = exc.message ?? String(exc);
|
|
410
735
|
this.emit('error', { tool: toolName, error: msg });
|
|
411
736
|
console.error(`[armoriq] beforeToolCallback failed (fail-closed): ${msg}`);
|
|
412
|
-
this.
|
|
413
|
-
this.blockedActions.set(toolName, 'block');
|
|
737
|
+
this.rememberBlockedCall(toolName, args, toolContext, 'block');
|
|
414
738
|
return {
|
|
415
739
|
error: `ArmorIQ enforcement error (fail-closed): ${msg}`,
|
|
416
740
|
armoriq_enforcement: {
|
|
@@ -425,41 +749,121 @@ class ArmorIQADKBundle {
|
|
|
425
749
|
}
|
|
426
750
|
return null;
|
|
427
751
|
}
|
|
428
|
-
async afterTool(tool, args,
|
|
752
|
+
async afterTool(tool, args, toolContext, toolResponse) {
|
|
753
|
+
if (this.closed)
|
|
754
|
+
return null;
|
|
429
755
|
const toolName = tool?.name ?? String(tool);
|
|
430
756
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
this.
|
|
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);
|
|
435
761
|
if (action !== 'hold') {
|
|
436
762
|
await this.ensureSession().report(toolName, args ?? {}, toolResponse, {
|
|
437
763
|
status: 'failed',
|
|
438
764
|
errorMessage: 'Blocked by policy',
|
|
765
|
+
operation: this.frameworkMcpOperation(toolName, this.toolOrdinal(toolContext), toolContext?.functionCallId),
|
|
439
766
|
});
|
|
440
767
|
}
|
|
441
768
|
return null;
|
|
442
769
|
}
|
|
443
|
-
|
|
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
|
+
});
|
|
444
782
|
}
|
|
445
783
|
catch (exc) {
|
|
446
784
|
console.warn(`[armoriq] afterToolCallback failed: ${exc.message}`);
|
|
447
785
|
}
|
|
448
786
|
return null;
|
|
449
787
|
}
|
|
450
|
-
/**
|
|
451
|
-
* Attach the three callbacks to the ADK-style agent. Save originals
|
|
452
|
-
* for uninstall().
|
|
453
|
-
*/
|
|
788
|
+
/** Attach this bundle to an ADK-style agent. */
|
|
454
789
|
install(agent) {
|
|
455
790
|
this.agent = agent;
|
|
456
|
-
this.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
agent.
|
|
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) => {
|
|
463
867
|
const a = args[0];
|
|
464
868
|
// Require `tool` specifically — anything else means we're in legacy
|
|
465
869
|
// positional mode and a is the BaseTool itself.
|
|
@@ -468,7 +872,7 @@ class ArmorIQADKBundle {
|
|
|
468
872
|
}
|
|
469
873
|
return this.beforeTool(a, args[1], args[2]);
|
|
470
874
|
};
|
|
471
|
-
|
|
875
|
+
const ourAfterTool = (...args) => {
|
|
472
876
|
const a = args[0];
|
|
473
877
|
// Require `tool` specifically — anything else means we're in legacy
|
|
474
878
|
// positional mode and a is the BaseTool itself.
|
|
@@ -477,15 +881,292 @@ class ArmorIQADKBundle {
|
|
|
477
881
|
}
|
|
478
882
|
return this.afterTool(a, args[1], args[2], args[3]);
|
|
479
883
|
};
|
|
480
|
-
|
|
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;
|
|
481
1157
|
}
|
|
482
1158
|
uninstall(agent) {
|
|
483
1159
|
const a = agent ?? this.agent;
|
|
484
1160
|
if (!a)
|
|
485
1161
|
return;
|
|
486
|
-
|
|
487
|
-
a
|
|
488
|
-
a
|
|
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();
|
|
489
1170
|
}
|
|
490
1171
|
}
|
|
491
1172
|
exports.ArmorIQADKBundle = ArmorIQADKBundle;
|