@armoriq/sdk-dev 0.6.10 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.d.ts.map +1 -1
  4. package/dist/_version.js +1 -1
  5. package/dist/_version.js.map +1 -1
  6. package/dist/cli/commands/auth.d.ts +12 -0
  7. package/dist/cli/commands/auth.d.ts.map +1 -1
  8. package/dist/cli/commands/auth.js +460 -42
  9. package/dist/cli/commands/auth.js.map +1 -1
  10. package/dist/client.d.ts +8 -15
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +20 -18
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +0 -17
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +1 -19
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +8 -14
  21. package/dist/index.js.map +1 -1
  22. package/dist/integrations/google_adk.d.ts +155 -7
  23. package/dist/integrations/google_adk.d.ts.map +1 -1
  24. package/dist/integrations/google_adk.js +727 -46
  25. package/dist/integrations/google_adk.js.map +1 -1
  26. package/dist/integrations/langchain.d.ts +48 -2
  27. package/dist/integrations/langchain.d.ts.map +1 -1
  28. package/dist/integrations/langchain.js +528 -33
  29. package/dist/integrations/langchain.js.map +1 -1
  30. package/dist/integrations/strands.d.ts +46 -1
  31. package/dist/integrations/strands.d.ts.map +1 -1
  32. package/dist/integrations/strands.js +421 -36
  33. package/dist/integrations/strands.js.map +1 -1
  34. package/dist/models.d.ts +2 -2
  35. package/dist/models.d.ts.map +1 -1
  36. package/dist/observability/content-capture.d.ts +75 -0
  37. package/dist/observability/content-capture.d.ts.map +1 -0
  38. package/dist/observability/content-capture.js +339 -0
  39. package/dist/observability/content-capture.js.map +1 -0
  40. package/dist/observability/index.d.ts +6 -7
  41. package/dist/observability/index.d.ts.map +1 -1
  42. package/dist/observability/index.js +18 -27
  43. package/dist/observability/index.js.map +1 -1
  44. package/dist/observability/otel-config.d.ts +47 -0
  45. package/dist/observability/otel-config.d.ts.map +1 -0
  46. package/dist/observability/otel-config.js +268 -0
  47. package/dist/observability/otel-config.js.map +1 -0
  48. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  49. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  50. package/dist/observability/otel-export-ceiling.js +264 -0
  51. package/dist/observability/otel-export-ceiling.js.map +1 -0
  52. package/dist/observability/otel-runtime.d.ts +103 -0
  53. package/dist/observability/otel-runtime.d.ts.map +1 -0
  54. package/dist/observability/otel-runtime.js +668 -0
  55. package/dist/observability/otel-runtime.js.map +1 -0
  56. package/dist/observability/otel-session.d.ts +168 -0
  57. package/dist/observability/otel-session.d.ts.map +1 -0
  58. package/dist/observability/otel-session.js +621 -0
  59. package/dist/observability/otel-session.js.map +1 -0
  60. package/dist/observability/otel-shutdown.d.ts +17 -0
  61. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  62. package/dist/observability/otel-shutdown.js +54 -0
  63. package/dist/observability/otel-shutdown.js.map +1 -0
  64. package/dist/observability/policy-lease.d.ts +22 -0
  65. package/dist/observability/policy-lease.d.ts.map +1 -0
  66. package/dist/observability/policy-lease.js +102 -0
  67. package/dist/observability/policy-lease.js.map +1 -0
  68. package/dist/plan_builder.d.ts +5 -4
  69. package/dist/plan_builder.d.ts.map +1 -1
  70. package/dist/plan_builder.js +14 -15
  71. package/dist/plan_builder.js.map +1 -1
  72. package/dist/session.d.ts +61 -93
  73. package/dist/session.d.ts.map +1 -1
  74. package/dist/session.js +388 -804
  75. package/dist/session.js.map +1 -1
  76. package/dist/token_usage.d.ts +11 -18
  77. package/dist/token_usage.d.ts.map +1 -1
  78. package/dist/token_usage.js +29 -94
  79. package/dist/token_usage.js.map +1 -1
  80. package/dist/tool_name.d.ts +18 -0
  81. package/dist/tool_name.d.ts.map +1 -0
  82. package/dist/tool_name.js +29 -0
  83. package/dist/tool_name.js.map +1 -0
  84. package/dist/tool_push.d.ts +28 -0
  85. package/dist/tool_push.d.ts.map +1 -0
  86. package/dist/tool_push.js +151 -0
  87. package/dist/tool_push.js.map +1 -0
  88. package/dist/tool_registry.d.ts +100 -0
  89. package/dist/tool_registry.d.ts.map +1 -0
  90. package/dist/tool_registry.js +440 -0
  91. package/dist/tool_registry.js.map +1 -0
  92. package/dist/tool_schema.d.ts +22 -0
  93. package/dist/tool_schema.d.ts.map +1 -0
  94. package/dist/tool_schema.js +163 -0
  95. package/dist/tool_schema.js.map +1 -0
  96. package/package.json +12 -7
@@ -31,6 +31,9 @@
31
31
  */
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.ArmorIQStrandsPlugin = exports.ArmorIQStrands = void 0;
34
+ const tool_push_1 = require("../tool_push");
35
+ const tool_registry_1 = require("../tool_registry");
36
+ const tool_name_1 = require("../tool_name");
34
37
  const STRANDS_PACKAGE = '@strands-agents/sdk';
35
38
  const EVENT_NAMES = [
36
39
  'AfterModelCallEvent',
@@ -50,6 +53,8 @@ function pickEventClasses(mod) {
50
53
  AfterModelCallEvent: src.AfterModelCallEvent,
51
54
  BeforeToolCallEvent: src.BeforeToolCallEvent,
52
55
  AfterToolCallEvent: src.AfterToolCallEvent,
56
+ AfterInvocationEvent: typeof src.AfterInvocationEvent === 'function' ? src.AfterInvocationEvent : undefined,
57
+ BeforeModelCallEvent: typeof src.BeforeModelCallEvent === 'function' ? src.BeforeModelCallEvent : undefined,
53
58
  };
54
59
  }
55
60
  /** Load the event classes without a static import, so strands stays optional. */
@@ -110,16 +115,23 @@ class ArmorIQStrands {
110
115
  async toolNameParser() {
111
116
  if (this.customParser)
112
117
  return this.customParser;
113
- const toolMap = (await this.bootstrap()).toolMap ?? {};
118
+ let toolMap = {};
119
+ try {
120
+ toolMap = (await this.bootstrap()).toolMap ?? {};
121
+ }
122
+ catch {
123
+ // A bootstrap outage must not prevent model-only work. Actual tool
124
+ // execution still passes through the session's fail-closed check.
125
+ console.warn('[armoriq] strands bootstrap unavailable; using safe tool-name fallback');
126
+ }
114
127
  const defaultMcp = this.defaultMcpName ?? 'unknown';
115
128
  return (toolName) => {
116
129
  const mcp = toolMap[toolName];
117
130
  if (mcp)
118
131
  return { mcp, action: toolName };
119
- if (toolName.includes('__')) {
120
- const [prefix, ...rest] = toolName.split('__');
121
- return { mcp: prefix, action: rest.join('__') };
122
- }
132
+ const split = (0, tool_name_1.splitPrefixedToolName)(toolName);
133
+ if (split)
134
+ return split;
123
135
  return { mcp: defaultMcp, action: toolName };
124
136
  };
125
137
  }
@@ -157,6 +169,13 @@ class ArmorIQStrandsPlugin {
157
169
  // for a cancelled tool and carries nothing that distinguishes it, so this is
158
170
  // the only way to know the tool never actually ran.
159
171
  cancelledToolUseIds = new Set();
172
+ hadToolError = false;
173
+ pendingTerminalStatus;
174
+ /** Framework-native request state. A plugin may serve concurrent streams. */
175
+ invocationRequests = new Map();
176
+ closedInvocationStates = new WeakSet();
177
+ closed = false;
178
+ legacyInvocationState;
160
179
  constructor(args) {
161
180
  this.factory = args.factory;
162
181
  this.scope = args.scope;
@@ -166,6 +185,17 @@ class ArmorIQStrandsPlugin {
166
185
  this.parser = args.parser;
167
186
  this.eventClasses = args.eventClasses ?? args.factory?.eventClasses;
168
187
  }
188
+ frameworkMcpOperation(toolName, itemOrdinal, toolCallId) {
189
+ return {
190
+ category: 'mcp',
191
+ name: 'mcp.execute',
192
+ toolType: 'mcp',
193
+ toolName,
194
+ callId: toolCallId,
195
+ mcpServer: this.parser(toolName).mcp,
196
+ planItemOrdinal: itemOrdinal,
197
+ };
198
+ }
169
199
  /** Strands Plugin entrypoint: register our hooks on the agent. */
170
200
  async initAgent(agent) {
171
201
  if (!this.eventClasses)
@@ -174,6 +204,10 @@ class ArmorIQStrandsPlugin {
174
204
  }
175
205
  /** Wire the AfterModelCall + BeforeToolCall hooks onto a Strands agent. */
176
206
  register(agent) {
207
+ (0, tool_registry_1.registerForClient)(this.factory.client, ...(() => {
208
+ const d = (0, tool_registry_1.declarationsFromStrandsAgent)(agent);
209
+ return [d.tools, d.servers];
210
+ })());
177
211
  const add = (agent.addHook ?? agent.hooks?.addCallback)?.bind(agent.addHook ? agent : agent.hooks);
178
212
  if (!add) {
179
213
  throw new Error('ArmorIQStrands: agent has no addHook/hooks.addCallback');
@@ -185,14 +219,100 @@ class ArmorIQStrandsPlugin {
185
219
  add(events.AfterModelCallEvent, (e) => this.onAfterModelCall(e));
186
220
  add(events.BeforeToolCallEvent, (e) => this.beforeToolCall(e));
187
221
  add(events.AfterToolCallEvent, (e) => this.onAfterToolCall(e));
222
+ // Strands guarantees BeforeModelCallEvent pairs with AfterModelCallEvent,
223
+ // so the model span opens here (before inference) and is only ever ended
224
+ // by onAfterModelCall — including the error path where stopData is
225
+ // absent. Without it (older strands, or a host-injected partial
226
+ // eventClasses map), onAfterModelCall falls back to opening the span
227
+ // itself, same as before this fix.
228
+ if (events.BeforeModelCallEvent) {
229
+ add(events.BeforeModelCallEvent, (e) => this.onBeforeModelCall(e));
230
+ }
231
+ // Strands guarantees this terminal hook runs after both successful and
232
+ // failed invocations. It is the bounded final-flush path when application
233
+ // code disconnects before it can call plugin.close() itself.
234
+ if (events.AfterInvocationEvent) {
235
+ add(events.AfterInvocationEvent, (e) => this.onAfterInvocation(e));
236
+ }
188
237
  }
189
238
  /** End and ship the request-owned plan session without closing the shared client. */
190
239
  async close(status = 'ok') {
191
- const session = this.session;
192
- const pendingPlanCapture = this.pendingPlanCapture;
193
- this.session = undefined;
194
- this.planStarted = false;
195
- this.cancelledToolUseIds.clear();
240
+ if (this.closed)
241
+ return;
242
+ this.closed = true;
243
+ await this.closeRequest(undefined, status);
244
+ }
245
+ isClosedInvocation(event) {
246
+ const state = event?.invocationState;
247
+ return (this.closed ||
248
+ (!!state && typeof state === 'object' && this.closedInvocationStates.has(state)));
249
+ }
250
+ requestFor(event) {
251
+ const invocationState = event?.invocationState;
252
+ if (!invocationState ||
253
+ (typeof invocationState !== 'object' && typeof invocationState !== 'function')) {
254
+ return undefined;
255
+ }
256
+ let request = this.invocationRequests.get(invocationState);
257
+ if (request)
258
+ return request;
259
+ // Keep direct/legacy callers working: existing consumers may preload a
260
+ // session before the first real framework event is delivered.
261
+ if (!this.legacyInvocationState &&
262
+ (this.session || this.planStarted || this.pendingPlanCapture)) {
263
+ this.legacyInvocationState = invocationState;
264
+ request = {
265
+ session: this.session,
266
+ planStarted: this.planStarted,
267
+ pendingPlanCapture: this.pendingPlanCapture,
268
+ cancelledToolUseIds: this.cancelledToolUseIds,
269
+ hadToolError: this.hadToolError,
270
+ pendingTerminalStatus: this.pendingTerminalStatus,
271
+ };
272
+ }
273
+ else {
274
+ request = { planStarted: false, cancelledToolUseIds: new Set(), hadToolError: false };
275
+ }
276
+ this.invocationRequests.set(invocationState, request);
277
+ return request;
278
+ }
279
+ async closeRequest(request, status, taskOutcome) {
280
+ // A public `plugin.close()` has no framework event to pass back, but it
281
+ // must still finalize the request state that a prior event adopted as the
282
+ // legacy request. Otherwise its root output is silently discarded.
283
+ const legacyRequest = !request && this.legacyInvocationState !== undefined
284
+ ? this.invocationRequests.get(this.legacyInvocationState)
285
+ : undefined;
286
+ const activeRequest = request ?? legacyRequest;
287
+ const session = activeRequest?.session ?? this.session;
288
+ const pendingPlanCapture = activeRequest?.pendingPlanCapture ?? this.pendingPlanCapture;
289
+ const isLegacyRequest = !!activeRequest &&
290
+ this.legacyInvocationState !== undefined &&
291
+ this.invocationRequests.get(this.legacyInvocationState) === activeRequest;
292
+ if (activeRequest) {
293
+ activeRequest.session = undefined;
294
+ activeRequest.planStarted = false;
295
+ activeRequest.cancelledToolUseIds.clear();
296
+ activeRequest.hadToolError = false;
297
+ activeRequest.pendingTerminalStatus = undefined;
298
+ if (isLegacyRequest) {
299
+ this.invocationRequests.delete(this.legacyInvocationState);
300
+ this.legacyInvocationState = undefined;
301
+ this.session = undefined;
302
+ this.planStarted = false;
303
+ this.pendingPlanCapture = undefined;
304
+ }
305
+ }
306
+ else {
307
+ this.session = undefined;
308
+ this.planStarted = false;
309
+ this.cancelledToolUseIds.clear();
310
+ this.hadToolError = false;
311
+ this.pendingTerminalStatus = undefined;
312
+ // A caller-owned close wins any in-flight legacy event. Its completion
313
+ // must not resurrect the detached request state.
314
+ this.legacyInvocationState = undefined;
315
+ }
196
316
  if (session && pendingPlanCapture && pendingPlanCapture.session === session) {
197
317
  try {
198
318
  await pendingPlanCapture.promise;
@@ -201,8 +321,29 @@ class ArmorIQStrandsPlugin {
201
321
  // Plan capture failures are handled by the lifecycle hook; teardown remains best-effort.
202
322
  }
203
323
  }
204
- if (session)
205
- await session.close(status);
324
+ const telemetryState = activeRequest ?? this;
325
+ const error = status === 'ok' ? undefined : new Error('terminal lifecycle');
326
+ const unfinishedToolOutcome = status === 'error' ? 'error' : status === 'timeout' ? 'timeout' :
327
+ status === 'disconnected' ? 'disconnected' : 'cancelled';
328
+ await Promise.all([
329
+ ...[...(telemetryState.toolSpans?.values?.() ?? [])].filter(Boolean).map((span) => session?.otelSession?.endTool(span, { outcome: unfinishedToolOutcome, error })),
330
+ ...(telemetryState.modelSpan
331
+ ? [session?.otelSession?.endModel(telemetryState.modelSpan, {}, error, status)]
332
+ : []),
333
+ ]);
334
+ telemetryState.toolSpans?.clear?.();
335
+ telemetryState.modelSpan = undefined;
336
+ telemetryState.invocationFailed = false;
337
+ const rootOutput = telemetryState.finalOutput;
338
+ telemetryState.finalOutput = undefined;
339
+ if (session) {
340
+ if (rootOutput === undefined && taskOutcome === undefined)
341
+ await session.close(status);
342
+ else if (rootOutput === undefined)
343
+ await session.close(status, taskOutcome);
344
+ else
345
+ await session.close(status, taskOutcome, { output: rootOutput });
346
+ }
206
347
  }
207
348
  emit(kind, payload) {
208
349
  if (!this.onEvent)
@@ -214,7 +355,22 @@ class ArmorIQStrandsPlugin {
214
355
  console.warn(`[armoriq] onEvent(${kind}) callback failed: ${exc.message}`);
215
356
  }
216
357
  }
217
- ensureSession() {
358
+ ensureSession(request) {
359
+ if (request) {
360
+ if (!request.session) {
361
+ request.session = this.scope.startSession({
362
+ mode: this.factory.mode,
363
+ validitySeconds: this.factory.validitySeconds,
364
+ toolNameParser: this.parser,
365
+ defaultMcpName: this.factory.defaultMcpName,
366
+ });
367
+ if (this.legacyInvocationState !== undefined &&
368
+ this.invocationRequests.get(this.legacyInvocationState) === request) {
369
+ this.session = request.session;
370
+ }
371
+ }
372
+ return request.session;
373
+ }
218
374
  if (!this.session) {
219
375
  this.session = this.scope.startSession({
220
376
  mode: this.factory.mode,
@@ -225,19 +381,45 @@ class ArmorIQStrandsPlugin {
225
381
  }
226
382
  return this.session;
227
383
  }
228
- async capturePlan(toolCalls) {
384
+ async capturePlan(toolCalls, request) {
229
385
  if (!toolCalls.length)
230
386
  return;
231
- const session = this.ensureSession();
387
+ const session = this.ensureSession(request);
232
388
  const pendingPlanCapture = { session, promise: session.startPlan(toolCalls, this.goal) };
233
- this.pendingPlanCapture = pendingPlanCapture;
389
+ if (request) {
390
+ request.pendingPlanCapture = pendingPlanCapture;
391
+ if (this.legacyInvocationState !== undefined &&
392
+ this.invocationRequests.get(this.legacyInvocationState) === request) {
393
+ this.pendingPlanCapture = pendingPlanCapture;
394
+ }
395
+ }
396
+ else
397
+ this.pendingPlanCapture = pendingPlanCapture;
234
398
  try {
235
399
  await pendingPlanCapture.promise;
236
- if (this.session === session)
400
+ if (request) {
401
+ if (request.session === session) {
402
+ request.planStarted = true;
403
+ if (this.legacyInvocationState !== undefined &&
404
+ this.invocationRequests.get(this.legacyInvocationState) === request) {
405
+ this.planStarted = true;
406
+ }
407
+ }
408
+ }
409
+ else if (this.session === session)
237
410
  this.planStarted = true;
238
411
  }
239
412
  finally {
240
- if (this.pendingPlanCapture === pendingPlanCapture)
413
+ if (request) {
414
+ if (request.pendingPlanCapture === pendingPlanCapture) {
415
+ request.pendingPlanCapture = undefined;
416
+ if (this.legacyInvocationState !== undefined &&
417
+ this.invocationRequests.get(this.legacyInvocationState) === request) {
418
+ this.pendingPlanCapture = undefined;
419
+ }
420
+ }
421
+ }
422
+ else if (this.pendingPlanCapture === pendingPlanCapture)
241
423
  this.pendingPlanCapture = undefined;
242
424
  }
243
425
  }
@@ -257,17 +439,107 @@ class ArmorIQStrandsPlugin {
257
439
  }
258
440
  return calls;
259
441
  }
442
+ /**
443
+ * Opens the model span before inference begins, so its recorded duration
444
+ * reflects real model latency rather than the ~0ms window between
445
+ * AfterModelCallEvent firing and this plugin ending the span in the same
446
+ * callback. `onAfterModelCall` always ends whatever span this opened —
447
+ * including when the paired event carries an error and no `stopData`.
448
+ */
449
+ /** Strands re-reads its tool registry before every model call; mirror it into the inventory. */
450
+ refreshDeclarations(agent) {
451
+ if (!agent)
452
+ return;
453
+ try {
454
+ const d = (0, tool_registry_1.declarationsFromStrandsAgent)(agent);
455
+ (0, tool_registry_1.registerForClient)(this.factory.client, d.tools, d.servers);
456
+ }
457
+ catch {
458
+ /* inventory problems never stop an agent */
459
+ }
460
+ (0, tool_push_1.noteModelCall)(this.factory.client);
461
+ }
462
+ async onBeforeModelCall(event) {
463
+ if (this.isClosedInvocation(event))
464
+ return;
465
+ this.refreshDeclarations(event?.agent);
466
+ const request = this.requestFor(event);
467
+ const telemetryState = request ?? this;
468
+ try {
469
+ const session = this.ensureSession(request);
470
+ const otel = session.otelSession;
471
+ // Strands does not consistently expose the original prompt in its model
472
+ // hooks. The caller supplied goal is the only stable request input;
473
+ // retain an explicit unavailable marker rather than inventing a prompt.
474
+ const input = this.goal === undefined
475
+ ? { availability: 'unavailable', reason: 'framework_goal_not_provided' }
476
+ : this.goal;
477
+ if (typeof otel?.beginRoot === 'function')
478
+ await otel.beginRoot({ input });
479
+ telemetryState.modelSpan = typeof otel?.beginModel === 'function'
480
+ ? await otel.beginModel(String(event?.model?.modelId ?? event?.model?.model ?? 'unknown'), { input })
481
+ : undefined;
482
+ }
483
+ catch (exc) {
484
+ console.warn(`[armoriq] strands model span start (before model) failed: ${exc.message}`);
485
+ }
486
+ }
260
487
  async onAfterModelCall(event) {
488
+ if (this.isClosedInvocation(event))
489
+ return;
490
+ const request = this.requestFor(event);
491
+ const telemetryState = request ?? this;
492
+ let modelError = event?.error;
493
+ let session;
261
494
  try {
495
+ session = this.ensureSession(request);
496
+ const otel = session.otelSession;
497
+ const input = this.goal === undefined
498
+ ? { availability: 'unavailable', reason: 'framework_goal_not_provided' }
499
+ : this.goal;
500
+ // Fallback for hosts whose eventClasses omit BeforeModelCallEvent (older
501
+ // strands, or a host-injected partial map): open the span here so a
502
+ // model call is still captured, even though its duration is then ~0ms.
503
+ if (telemetryState.modelSpan === undefined) {
504
+ if (typeof otel?.beginRoot === 'function')
505
+ await otel.beginRoot({ input });
506
+ telemetryState.modelSpan = typeof otel?.beginModel === 'function'
507
+ ? await otel.beginModel(String(event?.model?.modelId ?? event?.model?.model ?? 'unknown'), { input })
508
+ : undefined;
509
+ }
262
510
  const stopData = event?.stopData;
263
- if (!stopData)
264
- return; // model errored; nothing to capture this turn
511
+ if (!stopData) {
512
+ // Model errored (or was cancelled) this turn: no message to mint an
513
+ // intent plan from. The AfterInvocationEvent terminal hook carries no
514
+ // error field of its own (see terminalStatus()), so remember this
515
+ // here — it is how the terminal hook learns the invocation failed.
516
+ telemetryState.invocationFailed = true;
517
+ return;
518
+ }
519
+ telemetryState.invocationFailed = false;
520
+ telemetryState.finalOutput = stopData.message;
265
521
  const toolCalls = ArmorIQStrandsPlugin.toolCallsFromMessage(stopData?.message);
266
- await this.capturePlan(toolCalls);
522
+ const ordinals = telemetryState.toolOrdinals ?? new Map();
523
+ telemetryState.toolOrdinals = ordinals;
524
+ let ordinal = 0;
525
+ for (const content of stopData?.message?.content ?? []) {
526
+ const toolUse = content?.toolUse ?? content;
527
+ if (typeof toolUse?.toolUseId === 'string') {
528
+ ordinals.set(toolUse.toolUseId, ordinal);
529
+ ordinal += 1;
530
+ }
531
+ }
532
+ await this.capturePlan(toolCalls, request);
267
533
  }
268
534
  catch (exc) {
535
+ modelError = modelError ?? exc;
269
536
  console.warn(`[armoriq] strands plan capture (after model) failed: ${exc.message}`);
270
537
  }
538
+ finally {
539
+ const span = telemetryState.modelSpan;
540
+ telemetryState.modelSpan = undefined;
541
+ await session?.otelSession?.endModel(span ?? { span: null, name: 'gen_ai.chat' }, { ...(event?.stopData?.stopReason ? { 'gen_ai.response.finish_reasons': [event.stopData.stopReason] } : {}) }, modelError, undefined, event?.stopData?.message === undefined ? undefined : { output: event.stopData.message });
542
+ }
271
543
  }
272
544
  /**
273
545
  * Key a tool call so beforeToolCall can tell onAfterToolCall "this one never
@@ -278,26 +550,63 @@ class ArmorIQStrandsPlugin {
278
550
  return toolUse?.toolUseId ?? toolUse?.name ?? '';
279
551
  }
280
552
  /** Cancel the tool and remember that we did, so we never audit it as run. */
281
- cancelTool(event, toolUse, message) {
553
+ cancelTool(event, toolUse, message, request) {
282
554
  event.cancel = message;
283
555
  const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
284
556
  if (key)
285
- this.cancelledToolUseIds.add(key);
557
+ (request?.cancelledToolUseIds ?? this.cancelledToolUseIds).add(key);
286
558
  }
287
559
  async beforeToolCall(event) {
560
+ if (this.isClosedInvocation(event)) {
561
+ event.cancel = 'ArmorIQ request is closed';
562
+ return;
563
+ }
564
+ const request = this.requestFor(event);
565
+ const telemetryState = request ?? this;
288
566
  const toolUse = event?.toolUse ?? event?.tool_use ?? {};
289
567
  const toolName = toolUse.name ?? '';
290
568
  const args = toolUse.input ?? {};
569
+ const itemOrdinal = telemetryState.toolOrdinals?.get(toolUse.toolUseId);
570
+ let policySession;
571
+ let policySpan;
291
572
  try {
292
- if (!this.planStarted) {
573
+ if (!(request?.planStarted ?? this.planStarted)) {
293
574
  // Fail-safe: AfterModelCall normally mints the plan first. If it didn't,
294
575
  // mint from the tool actually being called — never the agent's full tool set.
295
- await this.capturePlan([{ name: toolName, args }]);
576
+ await this.capturePlan([{ name: toolName, args }], request);
577
+ }
578
+ const session = this.ensureSession(request);
579
+ policySession = session;
580
+ policySpan = await session.otelSession?.beginPolicy({
581
+ toolName, itemOrdinal, toolCallId: toolUse.toolUseId, arguments: args,
582
+ });
583
+ const decision = await session.check(toolName, args, this.userEmail, { emitOtel: false });
584
+ const policy = decision;
585
+ await session.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, {
586
+ decision: decision.allowed ? 'allow' : decision.action === 'hold' ? 'hold' : 'block',
587
+ policyName: decision.matchedPolicy,
588
+ ...(policy.policyId ? { policyId: policy.policyId } : {}),
589
+ policyVersion: policy.policyVersion,
590
+ policySource: policy.policySource,
591
+ policyReasonCode: decision.reason,
592
+ defaultAction: policy.defaultAction,
593
+ matchedRuleId: policy.matchedRuleId,
594
+ });
595
+ if (decision.allowed) {
596
+ const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
597
+ if (key) {
598
+ const toolSpans = telemetryState.toolSpans ?? new Map();
599
+ telemetryState.toolSpans = toolSpans;
600
+ toolSpans.set(key, await session.otelSession?.beginTool({
601
+ toolName,
602
+ itemOrdinal,
603
+ toolCallId: toolUse.toolUseId,
604
+ arguments: args,
605
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolUse.toolUseId),
606
+ }));
607
+ }
608
+ return;
296
609
  }
297
- const session = this.ensureSession();
298
- const decision = await session.check(toolName, args, this.userEmail);
299
- if (decision.allowed)
300
- return; // explicit allow → tool runs
301
610
  if (decision.action === 'hold') {
302
611
  this.emit('hold', {
303
612
  tool: toolName,
@@ -311,6 +620,18 @@ class ArmorIQStrandsPlugin {
311
620
  });
312
621
  if (outcome === 'approved') {
313
622
  this.emit('approved', { tool: toolName, delegationId: decision.delegationId });
623
+ const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
624
+ if (key) {
625
+ const toolSpans = telemetryState.toolSpans ?? new Map();
626
+ telemetryState.toolSpans = toolSpans;
627
+ toolSpans.set(key, await session.otelSession?.beginTool({
628
+ toolName,
629
+ itemOrdinal,
630
+ toolCallId: toolUse.toolUseId,
631
+ arguments: args,
632
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolUse.toolUseId),
633
+ }));
634
+ }
314
635
  return; // approved → do NOT cancel; the tool runs
315
636
  }
316
637
  this.emit(outcome, {
@@ -318,19 +639,20 @@ class ArmorIQStrandsPlugin {
318
639
  delegationId: decision.delegationId,
319
640
  reason: decision.reason,
320
641
  });
321
- this.cancelTool(event, toolUse, `ArmorIQ hold ${outcome}: ${decision.reason ?? 'approval required'}`);
642
+ this.cancelTool(event, toolUse, `ArmorIQ hold ${outcome}: ${decision.reason ?? 'approval required'}`, request);
322
643
  return;
323
644
  }
324
645
  // block (or any other non-allow decision)
325
646
  const reason = decision.reason ?? 'blocked by policy';
326
647
  this.emit('block', { tool: toolName, reason });
327
- this.cancelTool(event, toolUse, `ArmorIQ ${decision.action}: ${reason}`);
648
+ this.cancelTool(event, toolUse, `ArmorIQ ${decision.action}: ${reason}`, request);
328
649
  }
329
650
  catch (exc) {
651
+ await policySession?.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, { error: exc });
330
652
  // Fail closed: never let a tool run if enforcement errored — and surface it.
331
653
  const msg = exc.message ?? String(exc);
332
654
  this.emit('error', { tool: toolName, error: msg });
333
- this.cancelTool(event, toolUse, `ArmorIQ enforcement error (fail-closed): ${msg}`);
655
+ this.cancelTool(event, toolUse, `ArmorIQ enforcement error (fail-closed): ${msg}`, request);
334
656
  }
335
657
  }
336
658
  /**
@@ -346,31 +668,94 @@ class ArmorIQStrandsPlugin {
346
668
  * allowed to break the run.
347
669
  */
348
670
  async onAfterToolCall(event) {
671
+ if (this.isClosedInvocation(event))
672
+ return;
673
+ const request = this.requestFor(event);
674
+ const telemetryState = request ?? this;
349
675
  const toolUse = event?.toolUse ?? event?.tool_use ?? {};
350
676
  // Consume the mark so a later legitimate call to the same tool is reported.
351
677
  const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
352
- if (key && this.cancelledToolUseIds.delete(key))
353
- return; // we cancelled it; it never ran
354
- if (!this.session || !this.planStarted)
678
+ if (key && (request?.cancelledToolUseIds ?? this.cancelledToolUseIds).delete(key))
679
+ return;
680
+ const session = request?.session ?? this.session;
681
+ if (!session || !(request?.planStarted ?? this.planStarted))
355
682
  return;
356
683
  const toolName = toolUse.name ?? '';
357
684
  if (!toolName)
358
685
  return;
359
686
  const args = toolUse.input ?? {};
687
+ const itemOrdinal = telemetryState.toolOrdinals?.get(toolUse.toolUseId);
360
688
  const exc = event?.error;
689
+ if (exc) {
690
+ telemetryState.hadToolError = true;
691
+ telemetryState.pendingTerminalStatus = 'error';
692
+ }
361
693
  const status = exc ? 'error' : 'success';
362
694
  const errMsg = exc ? (exc.message ?? String(exc)) : undefined;
363
695
  const result = exc ? errMsg : event?.result;
364
696
  try {
365
- await this.session.report(toolName, args, result, {
697
+ const toolSpan = telemetryState.toolSpans?.get(key);
698
+ telemetryState.toolSpans?.delete(key);
699
+ await session.otelSession?.endTool(toolSpan ?? { span: null, name: 'armoriq.tool' }, {
700
+ outcome: exc ? 'error' : 'success', error: exc,
701
+ result,
702
+ });
703
+ await session.report(toolName, args, result, {
366
704
  status,
367
705
  errorMessage: errMsg,
706
+ emitOtel: false,
707
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolUse.toolUseId),
368
708
  });
369
709
  }
370
710
  catch (reportExc) {
371
711
  console.warn(`[armoriq] strands execution report failed for ${toolName}: ${reportExc.message}`);
372
712
  }
373
713
  }
714
+ async onAfterInvocation(event) {
715
+ const invocationState = event?.invocationState;
716
+ const request = this.requestFor(event);
717
+ const telemetryState = request ?? this;
718
+ const status = request?.pendingTerminalStatus ?? this.terminalStatus(event, telemetryState);
719
+ const taskOutcome = this.taskOutcomeFor(event, request, status);
720
+ await this.closeRequest(request, status, taskOutcome);
721
+ if (invocationState && typeof invocationState === 'object') {
722
+ this.closedInvocationStates.add(invocationState);
723
+ this.invocationRequests.delete(invocationState);
724
+ }
725
+ }
726
+ /**
727
+ * Strands' AfterInvocationEvent carries no `.error` field in the 1.11.2
728
+ * contract — an ordinary invocation failure (the framework's agent loop
729
+ * throwing, most commonly a non-retried model error) is rethrown by the
730
+ * generator right after this terminal hook runs, invisible to the event
731
+ * itself. `onAfterModelCall` tracks that on `telemetryState.invocationFailed`
732
+ * as it observes each model turn, so this only has to consult it here.
733
+ * Native AbortSignal reasons (timeout/disconnect/cancelled) are a more
734
+ * specific, intentional stop and take priority over a generic error.
735
+ */
736
+ terminalStatus(event, telemetryState) {
737
+ const signal = event?.agent?.cancelSignal;
738
+ if (signal?.aborted) {
739
+ const reason = signal.reason;
740
+ const label = String(reason?.name ?? reason?.code ?? reason ?? '').toLowerCase();
741
+ if (label.includes('timeout'))
742
+ return 'timeout';
743
+ if (label.includes('disconnect') || label.includes('socket') || label.includes('client')) {
744
+ return 'disconnected';
745
+ }
746
+ return 'cancelled';
747
+ }
748
+ if (event?.error || telemetryState?.invocationFailed)
749
+ return 'error';
750
+ return 'ok';
751
+ }
752
+ taskOutcomeFor(event, request, status) {
753
+ const stopReason = event?.result?.stopReason ?? event?.result?.stop_reason;
754
+ const hadToolError = request?.hadToolError ?? this.hadToolError;
755
+ return status === 'error' && hadToolError && stopReason === 'end_turn'
756
+ ? 'partially_completed'
757
+ : undefined;
758
+ }
374
759
  }
375
760
  exports.ArmorIQStrandsPlugin = ArmorIQStrandsPlugin;
376
761
  //# sourceMappingURL=strands.js.map