@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.
- package/README.md +168 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +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 +460 -42
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/index.js +0 -0
- 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 +168 -6
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +797 -103
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +51 -1
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +563 -30
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +48 -0
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +441 -27
- 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 +75 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +339 -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 +264 -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 +668 -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 +621 -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 -812
- 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 +13 -7
- package/dist/integrations/microsoft_copilot.d.ts +0 -84
- package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
- package/dist/integrations/microsoft_copilot.js +0 -126
- 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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
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:
|
|
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:
|
|
363
|
-
|
|
364
|
-
|
|
690
|
+
action: 'hold',
|
|
691
|
+
outcome,
|
|
692
|
+
reason: decision.reason,
|
|
693
|
+
matched_policy: decision.matchedPolicy,
|
|
365
694
|
tool: toolName,
|
|
366
|
-
delegation_id:
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
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);
|
|
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
|
-
|
|
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.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
a
|
|
475
|
-
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();
|
|
476
1170
|
}
|
|
477
1171
|
}
|
|
478
1172
|
exports.ArmorIQADKBundle = ArmorIQADKBundle;
|