@armoriq/sdk-dev 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.js +1 -1
  4. package/dist/cli/commands/auth.d.ts +12 -0
  5. package/dist/cli/commands/auth.d.ts.map +1 -1
  6. package/dist/cli/commands/auth.js +460 -42
  7. package/dist/cli/commands/auth.js.map +1 -1
  8. package/dist/cli/index.js +0 -0
  9. package/dist/client.d.ts +8 -15
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +20 -18
  12. package/dist/client.js.map +1 -1
  13. package/dist/config.d.ts +0 -17
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -19
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8 -14
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/google_adk.d.ts +168 -6
  22. package/dist/integrations/google_adk.d.ts.map +1 -1
  23. package/dist/integrations/google_adk.js +797 -103
  24. package/dist/integrations/google_adk.js.map +1 -1
  25. package/dist/integrations/langchain.d.ts +51 -1
  26. package/dist/integrations/langchain.d.ts.map +1 -1
  27. package/dist/integrations/langchain.js +563 -30
  28. package/dist/integrations/langchain.js.map +1 -1
  29. package/dist/integrations/strands.d.ts +48 -0
  30. package/dist/integrations/strands.d.ts.map +1 -1
  31. package/dist/integrations/strands.js +441 -27
  32. package/dist/integrations/strands.js.map +1 -1
  33. package/dist/models.d.ts +2 -2
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/observability/content-capture.d.ts +75 -0
  36. package/dist/observability/content-capture.d.ts.map +1 -0
  37. package/dist/observability/content-capture.js +339 -0
  38. package/dist/observability/content-capture.js.map +1 -0
  39. package/dist/observability/index.d.ts +6 -7
  40. package/dist/observability/index.d.ts.map +1 -1
  41. package/dist/observability/index.js +18 -27
  42. package/dist/observability/index.js.map +1 -1
  43. package/dist/observability/otel-config.d.ts +47 -0
  44. package/dist/observability/otel-config.d.ts.map +1 -0
  45. package/dist/observability/otel-config.js +268 -0
  46. package/dist/observability/otel-config.js.map +1 -0
  47. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  48. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  49. package/dist/observability/otel-export-ceiling.js +264 -0
  50. package/dist/observability/otel-export-ceiling.js.map +1 -0
  51. package/dist/observability/otel-runtime.d.ts +103 -0
  52. package/dist/observability/otel-runtime.d.ts.map +1 -0
  53. package/dist/observability/otel-runtime.js +668 -0
  54. package/dist/observability/otel-runtime.js.map +1 -0
  55. package/dist/observability/otel-session.d.ts +168 -0
  56. package/dist/observability/otel-session.d.ts.map +1 -0
  57. package/dist/observability/otel-session.js +621 -0
  58. package/dist/observability/otel-session.js.map +1 -0
  59. package/dist/observability/otel-shutdown.d.ts +17 -0
  60. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  61. package/dist/observability/otel-shutdown.js +54 -0
  62. package/dist/observability/otel-shutdown.js.map +1 -0
  63. package/dist/observability/policy-lease.d.ts +22 -0
  64. package/dist/observability/policy-lease.d.ts.map +1 -0
  65. package/dist/observability/policy-lease.js +102 -0
  66. package/dist/observability/policy-lease.js.map +1 -0
  67. package/dist/plan_builder.d.ts +5 -4
  68. package/dist/plan_builder.d.ts.map +1 -1
  69. package/dist/plan_builder.js +14 -15
  70. package/dist/plan_builder.js.map +1 -1
  71. package/dist/session.d.ts +61 -93
  72. package/dist/session.d.ts.map +1 -1
  73. package/dist/session.js +388 -812
  74. package/dist/session.js.map +1 -1
  75. package/dist/token_usage.d.ts +11 -18
  76. package/dist/token_usage.d.ts.map +1 -1
  77. package/dist/token_usage.js +29 -94
  78. package/dist/token_usage.js.map +1 -1
  79. package/dist/tool_name.d.ts +18 -0
  80. package/dist/tool_name.d.ts.map +1 -0
  81. package/dist/tool_name.js +29 -0
  82. package/dist/tool_name.js.map +1 -0
  83. package/dist/tool_push.d.ts +28 -0
  84. package/dist/tool_push.d.ts.map +1 -0
  85. package/dist/tool_push.js +151 -0
  86. package/dist/tool_push.js.map +1 -0
  87. package/dist/tool_registry.d.ts +100 -0
  88. package/dist/tool_registry.d.ts.map +1 -0
  89. package/dist/tool_registry.js +440 -0
  90. package/dist/tool_registry.js.map +1 -0
  91. package/dist/tool_schema.d.ts +22 -0
  92. package/dist/tool_schema.d.ts.map +1 -0
  93. package/dist/tool_schema.js +163 -0
  94. package/dist/tool_schema.js.map +1 -0
  95. package/package.json +13 -7
  96. package/dist/integrations/microsoft_copilot.d.ts +0 -84
  97. package/dist/integrations/microsoft_copilot.d.ts.map +0 -1
  98. package/dist/integrations/microsoft_copilot.js +0 -126
  99. package/dist/integrations/microsoft_copilot.js.map +0 -1
@@ -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
  }
@@ -151,11 +163,19 @@ class ArmorIQStrandsPlugin {
151
163
  parser;
152
164
  session;
153
165
  planStarted = false;
166
+ pendingPlanCapture;
154
167
  eventClasses;
155
168
  // toolUseIds we cancelled in beforeToolCall. AfterToolCallEvent still fires
156
169
  // for a cancelled tool and carries nothing that distinguishes it, so this is
157
170
  // the only way to know the tool never actually ran.
158
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;
159
179
  constructor(args) {
160
180
  this.factory = args.factory;
161
181
  this.scope = args.scope;
@@ -165,6 +185,17 @@ class ArmorIQStrandsPlugin {
165
185
  this.parser = args.parser;
166
186
  this.eventClasses = args.eventClasses ?? args.factory?.eventClasses;
167
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
+ }
168
199
  /** Strands Plugin entrypoint: register our hooks on the agent. */
169
200
  async initAgent(agent) {
170
201
  if (!this.eventClasses)
@@ -173,6 +204,10 @@ class ArmorIQStrandsPlugin {
173
204
  }
174
205
  /** Wire the AfterModelCall + BeforeToolCall hooks onto a Strands agent. */
175
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
+ })());
176
211
  const add = (agent.addHook ?? agent.hooks?.addCallback)?.bind(agent.addHook ? agent : agent.hooks);
177
212
  if (!add) {
178
213
  throw new Error('ArmorIQStrands: agent has no addHook/hooks.addCallback');
@@ -184,6 +219,131 @@ class ArmorIQStrandsPlugin {
184
219
  add(events.AfterModelCallEvent, (e) => this.onAfterModelCall(e));
185
220
  add(events.BeforeToolCallEvent, (e) => this.beforeToolCall(e));
186
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
+ }
237
+ }
238
+ /** End and ship the request-owned plan session without closing the shared client. */
239
+ async close(status = 'ok') {
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
+ }
316
+ if (session && pendingPlanCapture && pendingPlanCapture.session === session) {
317
+ try {
318
+ await pendingPlanCapture.promise;
319
+ }
320
+ catch {
321
+ // Plan capture failures are handled by the lifecycle hook; teardown remains best-effort.
322
+ }
323
+ }
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
+ }
187
347
  }
188
348
  emit(kind, payload) {
189
349
  if (!this.onEvent)
@@ -195,7 +355,22 @@ class ArmorIQStrandsPlugin {
195
355
  console.warn(`[armoriq] onEvent(${kind}) callback failed: ${exc.message}`);
196
356
  }
197
357
  }
198
- 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
+ }
199
374
  if (!this.session) {
200
375
  this.session = this.scope.startSession({
201
376
  mode: this.factory.mode,
@@ -206,11 +381,47 @@ class ArmorIQStrandsPlugin {
206
381
  }
207
382
  return this.session;
208
383
  }
209
- async capturePlan(toolCalls) {
384
+ async capturePlan(toolCalls, request) {
210
385
  if (!toolCalls.length)
211
386
  return;
212
- await this.ensureSession().startPlan(toolCalls, this.goal);
213
- this.planStarted = true;
387
+ const session = this.ensureSession(request);
388
+ const pendingPlanCapture = { session, promise: session.startPlan(toolCalls, this.goal) };
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;
398
+ try {
399
+ await pendingPlanCapture.promise;
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)
410
+ this.planStarted = true;
411
+ }
412
+ finally {
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)
423
+ this.pendingPlanCapture = undefined;
424
+ }
214
425
  }
215
426
  /** Extract {name, args} for each toolUse block in a Strands model message. */
216
427
  static toolCallsFromMessage(message) {
@@ -228,17 +439,107 @@ class ArmorIQStrandsPlugin {
228
439
  }
229
440
  return calls;
230
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
+ }
231
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;
232
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
+ }
233
510
  const stopData = event?.stopData;
234
- if (!stopData)
235
- 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;
236
521
  const toolCalls = ArmorIQStrandsPlugin.toolCallsFromMessage(stopData?.message);
237
- 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);
238
533
  }
239
534
  catch (exc) {
535
+ modelError = modelError ?? exc;
240
536
  console.warn(`[armoriq] strands plan capture (after model) failed: ${exc.message}`);
241
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
+ }
242
543
  }
243
544
  /**
244
545
  * Key a tool call so beforeToolCall can tell onAfterToolCall "this one never
@@ -249,26 +550,63 @@ class ArmorIQStrandsPlugin {
249
550
  return toolUse?.toolUseId ?? toolUse?.name ?? '';
250
551
  }
251
552
  /** Cancel the tool and remember that we did, so we never audit it as run. */
252
- cancelTool(event, toolUse, message) {
553
+ cancelTool(event, toolUse, message, request) {
253
554
  event.cancel = message;
254
555
  const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
255
556
  if (key)
256
- this.cancelledToolUseIds.add(key);
557
+ (request?.cancelledToolUseIds ?? this.cancelledToolUseIds).add(key);
257
558
  }
258
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;
259
566
  const toolUse = event?.toolUse ?? event?.tool_use ?? {};
260
567
  const toolName = toolUse.name ?? '';
261
568
  const args = toolUse.input ?? {};
569
+ const itemOrdinal = telemetryState.toolOrdinals?.get(toolUse.toolUseId);
570
+ let policySession;
571
+ let policySpan;
262
572
  try {
263
- if (!this.planStarted) {
573
+ if (!(request?.planStarted ?? this.planStarted)) {
264
574
  // Fail-safe: AfterModelCall normally mints the plan first. If it didn't,
265
575
  // mint from the tool actually being called — never the agent's full tool set.
266
- 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;
267
609
  }
268
- const session = this.ensureSession();
269
- const decision = await session.check(toolName, args, this.userEmail);
270
- if (decision.allowed)
271
- return; // explicit allow → tool runs
272
610
  if (decision.action === 'hold') {
273
611
  this.emit('hold', {
274
612
  tool: toolName,
@@ -282,6 +620,18 @@ class ArmorIQStrandsPlugin {
282
620
  });
283
621
  if (outcome === 'approved') {
284
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
+ }
285
635
  return; // approved → do NOT cancel; the tool runs
286
636
  }
287
637
  this.emit(outcome, {
@@ -289,19 +639,20 @@ class ArmorIQStrandsPlugin {
289
639
  delegationId: decision.delegationId,
290
640
  reason: decision.reason,
291
641
  });
292
- this.cancelTool(event, toolUse, `ArmorIQ hold ${outcome}: ${decision.reason ?? 'approval required'}`);
642
+ this.cancelTool(event, toolUse, `ArmorIQ hold ${outcome}: ${decision.reason ?? 'approval required'}`, request);
293
643
  return;
294
644
  }
295
645
  // block (or any other non-allow decision)
296
646
  const reason = decision.reason ?? 'blocked by policy';
297
647
  this.emit('block', { tool: toolName, reason });
298
- this.cancelTool(event, toolUse, `ArmorIQ ${decision.action}: ${reason}`);
648
+ this.cancelTool(event, toolUse, `ArmorIQ ${decision.action}: ${reason}`, request);
299
649
  }
300
650
  catch (exc) {
651
+ await policySession?.otelSession?.endPolicy(policySpan ?? { span: null, name: 'armoriq.policy.evaluate' }, { error: exc });
301
652
  // Fail closed: never let a tool run if enforcement errored — and surface it.
302
653
  const msg = exc.message ?? String(exc);
303
654
  this.emit('error', { tool: toolName, error: msg });
304
- this.cancelTool(event, toolUse, `ArmorIQ enforcement error (fail-closed): ${msg}`);
655
+ this.cancelTool(event, toolUse, `ArmorIQ enforcement error (fail-closed): ${msg}`, request);
305
656
  }
306
657
  }
307
658
  /**
@@ -317,31 +668,94 @@ class ArmorIQStrandsPlugin {
317
668
  * allowed to break the run.
318
669
  */
319
670
  async onAfterToolCall(event) {
671
+ if (this.isClosedInvocation(event))
672
+ return;
673
+ const request = this.requestFor(event);
674
+ const telemetryState = request ?? this;
320
675
  const toolUse = event?.toolUse ?? event?.tool_use ?? {};
321
676
  // Consume the mark so a later legitimate call to the same tool is reported.
322
677
  const key = ArmorIQStrandsPlugin.cancelKey(toolUse);
323
- if (key && this.cancelledToolUseIds.delete(key))
324
- return; // we cancelled it; it never ran
325
- 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))
326
682
  return;
327
683
  const toolName = toolUse.name ?? '';
328
684
  if (!toolName)
329
685
  return;
330
686
  const args = toolUse.input ?? {};
687
+ const itemOrdinal = telemetryState.toolOrdinals?.get(toolUse.toolUseId);
331
688
  const exc = event?.error;
689
+ if (exc) {
690
+ telemetryState.hadToolError = true;
691
+ telemetryState.pendingTerminalStatus = 'error';
692
+ }
332
693
  const status = exc ? 'error' : 'success';
333
694
  const errMsg = exc ? (exc.message ?? String(exc)) : undefined;
334
695
  const result = exc ? errMsg : event?.result;
335
696
  try {
336
- 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, {
337
704
  status,
338
705
  errorMessage: errMsg,
706
+ emitOtel: false,
707
+ operation: this.frameworkMcpOperation(toolName, itemOrdinal, toolUse.toolUseId),
339
708
  });
340
709
  }
341
710
  catch (reportExc) {
342
711
  console.warn(`[armoriq] strands execution report failed for ${toolName}: ${reportExc.message}`);
343
712
  }
344
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
+ }
345
759
  }
346
760
  exports.ArmorIQStrandsPlugin = ArmorIQStrandsPlugin;
347
761
  //# sourceMappingURL=strands.js.map