@archmax-ai/harness 2.0.1 → 2.2.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 (65) hide show
  1. package/README.md +11 -1
  2. package/dist/assembly/compose.d.ts +11 -1
  3. package/dist/assembly/compose.js +29 -5
  4. package/dist/assembly/compose.js.map +1 -1
  5. package/dist/assembly/index.js +10 -2
  6. package/dist/assembly/index.js.map +1 -1
  7. package/dist/assembly/plain.js +9 -2
  8. package/dist/assembly/plain.js.map +1 -1
  9. package/dist/assembly/telemetry-middleware.d.ts +11 -0
  10. package/dist/assembly/telemetry-middleware.js +65 -0
  11. package/dist/assembly/telemetry-middleware.js.map +1 -0
  12. package/dist/authoring-skill/archmax-harness/references/backend-integration.md +20 -0
  13. package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +4 -0
  14. package/dist/core/events.d.ts +2 -0
  15. package/dist/core/events.js +4 -0
  16. package/dist/core/events.js.map +1 -1
  17. package/dist/core/file-operations.js +2 -1
  18. package/dist/core/file-operations.js.map +1 -1
  19. package/dist/core/state-listing.d.ts +14 -0
  20. package/dist/core/state-listing.js +23 -0
  21. package/dist/core/state-listing.js.map +1 -0
  22. package/dist/core/telemetry.d.ts +213 -0
  23. package/dist/core/telemetry.js +705 -0
  24. package/dist/core/telemetry.js.map +1 -0
  25. package/dist/core/workspace-router.d.ts +13 -1
  26. package/dist/core/workspace-router.js +34 -6
  27. package/dist/core/workspace-router.js.map +1 -1
  28. package/dist/core/workspace.d.ts +15 -0
  29. package/dist/core/workspace.js +28 -0
  30. package/dist/core/workspace.js.map +1 -1
  31. package/dist/env.js +92 -1
  32. package/dist/env.js.map +1 -1
  33. package/dist/kernel/kernel.d.ts +6 -0
  34. package/dist/kernel/kernel.js +55 -1
  35. package/dist/kernel/kernel.js.map +1 -1
  36. package/dist/machine/allow.d.ts +2 -0
  37. package/dist/machine/allow.js +11 -7
  38. package/dist/machine/allow.js.map +1 -1
  39. package/dist/machine/variables.d.ts +19 -2
  40. package/dist/machine/variables.js +47 -3
  41. package/dist/machine/variables.js.map +1 -1
  42. package/dist/sandbox/ptc-gateway.d.ts +7 -0
  43. package/dist/sandbox/ptc-gateway.js +51 -19
  44. package/dist/sandbox/ptc-gateway.js.map +1 -1
  45. package/dist/workflow/automated.d.ts +3 -0
  46. package/dist/workflow/automated.js +2 -0
  47. package/dist/workflow/automated.js.map +1 -1
  48. package/dist/workflow/control-tools.d.ts +5 -0
  49. package/dist/workflow/control-tools.js +15 -1
  50. package/dist/workflow/control-tools.js.map +1 -1
  51. package/dist/workflow/governance.d.ts +7 -1
  52. package/dist/workflow/governance.js +97 -34
  53. package/dist/workflow/governance.js.map +1 -1
  54. package/dist/workflow/grader-state.d.ts +5 -17
  55. package/dist/workflow/grader-state.js +85 -6
  56. package/dist/workflow/grader-state.js.map +1 -1
  57. package/dist/workflow/middleware.d.ts +3 -0
  58. package/dist/workflow/middleware.js +2 -0
  59. package/dist/workflow/middleware.js.map +1 -1
  60. package/dist/workflow/turn-runner.d.ts +12 -0
  61. package/dist/workflow/turn-runner.js +91 -16
  62. package/dist/workflow/turn-runner.js.map +1 -1
  63. package/package.json +8 -1
  64. package/skills/archmax-harness/references/backend-integration.md +20 -0
  65. package/skills/archmax-harness/references/workflow-schema.md +4 -0
@@ -0,0 +1,705 @@
1
+ /**
2
+ * OpenTelemetry for the runtime: one span per turn, per visit to a state, per
3
+ * model call and per tool call, and the GenAI client metrics for every model
4
+ * call — reported through `@opentelemetry/api` alone (a peer dependency), so a
5
+ * host sees them in its own trace once it registers an SDK, and nothing at all
6
+ * happens when it registers none.
7
+ *
8
+ * This module owns every span name, attribute name and recorded value, which
9
+ * is how the metadata-only rule holds: it takes slugs, model ids, counts and
10
+ * outcomes, never a message, an argument, a result or an error's message, and
11
+ * it records no exception.
12
+ *
13
+ * Turn, model-call and tool-call spans are made at their call sites and are
14
+ * active while the call runs, so whatever the call causes — the provider's HTTP
15
+ * request, a host tool's own spans, a sub-workflow's child turn — nests under
16
+ * it. A state visit is not one call: the sites that move a session report it on
17
+ * the event stream, which {@link Telemetry.observe} follows, and its children
18
+ * are parented to it explicitly ({@link Telemetry.parentFor}).
19
+ */
20
+ import { context, createContextKey, INVALID_SPAN_CONTEXT, metrics, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
21
+ import { isGraphBubbleUp, isGraphInterrupt } from "@langchain/langgraph";
22
+ import { PACKAGE_VERSION } from "../env.js";
23
+ import { currentEventContext } from "./events.js";
24
+ import { addSummaries, extractUsage, summarizeUsage, } from "./usage.js";
25
+ /** The instrumentation scope every span and metric is reported under. */
26
+ export const TELEMETRY_SCOPE = "@archmax-ai/harness";
27
+ /** Every attribute the runtime records, by the name the backend reads. */
28
+ export const TELEMETRY_ATTRIBUTES = {
29
+ operation: "gen_ai.operation.name",
30
+ agentName: "gen_ai.agent.name",
31
+ conversationId: "gen_ai.conversation.id",
32
+ provider: "gen_ai.provider.name",
33
+ requestModel: "gen_ai.request.model",
34
+ responseModel: "gen_ai.response.model",
35
+ finishReasons: "gen_ai.response.finish_reasons",
36
+ inputTokens: "gen_ai.usage.input_tokens",
37
+ outputTokens: "gen_ai.usage.output_tokens",
38
+ cacheReadTokens: "gen_ai.usage.cache_read.input_tokens",
39
+ cacheCreationTokens: "gen_ai.usage.cache_creation.input_tokens",
40
+ cost: "gen_ai.usage.cost",
41
+ toolName: "gen_ai.tool.name",
42
+ toolCallId: "gen_ai.tool.call.id",
43
+ toolType: "gen_ai.tool.type",
44
+ tokenType: "gen_ai.token.type",
45
+ errorType: "error.type",
46
+ state: "archmax.state",
47
+ stateType: "archmax.state.type",
48
+ stateNext: "archmax.state.next",
49
+ stateResumed: "archmax.state.resumed",
50
+ stateParked: "archmax.state.parked",
51
+ raiseCode: "archmax.raise.code",
52
+ turnResume: "archmax.turn.resume",
53
+ sessionStatus: "archmax.session.status",
54
+ dispatchId: "archmax.dispatch.id",
55
+ modelRole: "archmax.model.role",
56
+ responseToolCalls: "archmax.response.tool_calls",
57
+ toolOrigin: "archmax.tool.origin",
58
+ toolOutcome: "archmax.tool.outcome",
59
+ toolRule: "archmax.tool.rule",
60
+ };
61
+ const A = TELEMETRY_ATTRIBUTES;
62
+ /** The two GenAI client metrics, by name. */
63
+ export const TELEMETRY_METRICS = {
64
+ operationDuration: "gen_ai.client.operation.duration",
65
+ tokenUsage: "gen_ai.client.token.usage",
66
+ };
67
+ /** The bucket boundaries the GenAI conventions advise for each metric. */
68
+ const DURATION_BUCKETS = [
69
+ 0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
70
+ ];
71
+ const TOKEN_BUCKETS = [
72
+ 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864,
73
+ ];
74
+ // --- What may be recorded ------------------------------------------------------
75
+ /** A tool name, raise code or provider name the runtime may record: a plain identifier. */
76
+ const IDENTIFIER = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,63}$/;
77
+ /** A model id: an identifier that may also carry `/` and `@` (`openai/gpt-6-luna`). */
78
+ const MODEL_ID = /^[A-Za-z0-9_][A-Za-z0-9_.:/@+-]{0,127}$/;
79
+ /** A tool call's id: what providers and the runtime issue (`call_…`, `toolu_…`, `ptc:12`). */
80
+ const CALL_ID = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
81
+ /** An error class name: PascalCase, ending `Error` or `Exception` — never free text in a `name`. */
82
+ const CLASS_NAME = /^[A-Z][A-Za-z0-9]{0,62}(Error|Exception)$/;
83
+ /**
84
+ * A tool's name as a span reports it: the name when it is a plain tool
85
+ * identifier, else `unknown` — a model can call a tool by any string at all.
86
+ */
87
+ export function reportedToolName(name) {
88
+ return IDENTIFIER.test(name) ? name : "unknown";
89
+ }
90
+ /** A raise's code when it is a short token, else nothing: the raiser chose it. */
91
+ export function reportedRaiseCode(code) {
92
+ return typeof code === "string" && IDENTIFIER.test(code) ? code : undefined;
93
+ }
94
+ /** A model id when it reads as one, else nothing. */
95
+ export function reportedModelId(id) {
96
+ return typeof id === "string" && MODEL_ID.test(id) ? id : undefined;
97
+ }
98
+ /** A tool call's id as a span reports it, when it reads as an id. */
99
+ export function reportedCallId(id) {
100
+ return typeof id === "string" && CALL_ID.test(id) ? id : undefined;
101
+ }
102
+ /**
103
+ * An error's `error.type`: the HTTP status it carries (`429`), else its class
104
+ * name (`AbortError`, from its `name` or its constructor's), else `Error`.
105
+ * Never its message, which can quote what the call was given, and never a
106
+ * `name` that is not shaped like a class name.
107
+ */
108
+ export function errorTypeOf(err) {
109
+ const e = err;
110
+ const status = e?.status ?? e?.response?.status;
111
+ if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status < 600)
112
+ return String(status);
113
+ for (const name of [
114
+ e?.name,
115
+ e?.constructor?.name,
116
+ ]) {
117
+ if (typeof name === "string" && CLASS_NAME.test(name))
118
+ return name;
119
+ }
120
+ return "Error";
121
+ }
122
+ /** Run a reporting step: telemetry never fails the work it reports. */
123
+ function safely(step) {
124
+ try {
125
+ step();
126
+ }
127
+ catch {
128
+ // A span or a metric that cannot be recorded is lost, never the call's failure.
129
+ }
130
+ }
131
+ /** Annotate `span`, then end it: a span whose annotation fails still ends. */
132
+ function finishSpan(span, annotate) {
133
+ safely(annotate);
134
+ safely(() => span.end());
135
+ }
136
+ /** LangChain's model types, mapped to the provider names the GenAI conventions use. */
137
+ const PROVIDERS = {
138
+ openai: "openai",
139
+ azure_openai: "azure.ai.openai",
140
+ anthropic: "anthropic",
141
+ bedrock: "aws.bedrock",
142
+ amazon_bedrock: "aws.bedrock",
143
+ "bedrock-converse": "aws.bedrock",
144
+ vertexai: "gcp.vertex_ai",
145
+ "google-vertexai": "gcp.vertex_ai",
146
+ google_genai: "gcp.gemini",
147
+ "google-genai": "gcp.gemini",
148
+ mistralai: "mistral_ai",
149
+ mistral: "mistral_ai",
150
+ groq: "groq",
151
+ deepseek: "deepseek",
152
+ xai: "x_ai",
153
+ cohere: "cohere",
154
+ perplexity: "perplexity",
155
+ };
156
+ /** The `gen_ai.provider.name` of a chat model, from LangChain's own model type. */
157
+ export function providerOf(model) {
158
+ const typed = model;
159
+ let type;
160
+ try {
161
+ type = typeof typed?._llmType === "function" ? typed._llmType() : undefined;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ if (typeof type !== "string")
167
+ return undefined;
168
+ return PROVIDERS[type.toLowerCase()] ?? (IDENTIFIER.test(type) ? type : undefined);
169
+ }
170
+ /** The finish reasons a response gives, as short tokens. */
171
+ function finishReasonsOf(message) {
172
+ const meta = message
173
+ ?.response_metadata;
174
+ const reasons = [meta?.finish_reason, meta?.stop_reason].filter((reason) => typeof reason === "string" && IDENTIFIER.test(reason));
175
+ return [...new Set(reasons)];
176
+ }
177
+ /** How many tool calls a response asked for. */
178
+ function toolCallCount(message) {
179
+ const calls = message?.tool_calls;
180
+ return Array.isArray(calls) ? calls.length : 0;
181
+ }
182
+ /** The AI message a model call settled with: the message itself, or the last one a command carries. */
183
+ function responseMessage(result) {
184
+ const update = result?.update;
185
+ if (Array.isArray(update?.messages)) {
186
+ return [...update.messages]
187
+ .reverse()
188
+ .find((m) => m?.tool_calls !== undefined);
189
+ }
190
+ return result;
191
+ }
192
+ function usageAttributes(summary) {
193
+ return {
194
+ [A.inputTokens]: summary.inputTokens,
195
+ [A.outputTokens]: summary.outputTokens,
196
+ [A.cacheReadTokens]: summary.cacheReadTokens,
197
+ [A.cacheCreationTokens]: summary.cacheCreationTokens,
198
+ ...(summary.costUsd !== undefined ? { [A.cost]: summary.costUsd } : {}),
199
+ };
200
+ }
201
+ // --- Instruments ---------------------------------------------------------------
202
+ function tracer() {
203
+ return trace.getTracer(TELEMETRY_SCOPE, PACKAGE_VERSION);
204
+ }
205
+ /**
206
+ * Start a span, or — when the host's SDK throws starting it (a sampler, a span
207
+ * processor's `onStart`) — a non-recording one: telemetry never fails the work
208
+ * it reports.
209
+ */
210
+ function startSpan(name, options, parent) {
211
+ try {
212
+ return tracer().startSpan(name, options, parent);
213
+ }
214
+ catch {
215
+ return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
216
+ }
217
+ }
218
+ /** Whether a span records, read without trusting the host's SDK not to throw. */
219
+ function isRecording(span) {
220
+ try {
221
+ return span.isRecording();
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ let instruments;
228
+ /**
229
+ * The histograms, built against the meter provider registered now. The metrics
230
+ * API has no proxy meter: an instrument made before the host registers its
231
+ * provider would stay a no-op, so they are rebuilt whenever the provider changes.
232
+ */
233
+ function metricInstruments() {
234
+ const provider = metrics.getMeterProvider();
235
+ if (instruments?.provider !== provider) {
236
+ const meter = provider.getMeter(TELEMETRY_SCOPE, PACKAGE_VERSION);
237
+ instruments = {
238
+ provider,
239
+ duration: meter.createHistogram(TELEMETRY_METRICS.operationDuration, {
240
+ unit: "s",
241
+ description: "GenAI operation duration.",
242
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS },
243
+ }),
244
+ tokens: meter.createHistogram(TELEMETRY_METRICS.tokenUsage, {
245
+ unit: "{token}",
246
+ description: "Number of input and output tokens used.",
247
+ advice: { explicitBucketBoundaries: TOKEN_BUCKETS },
248
+ }),
249
+ };
250
+ }
251
+ return instruments;
252
+ }
253
+ // --- Context -------------------------------------------------------------------
254
+ /**
255
+ * Marks a context as inside a tool call, by the session that made the call: a
256
+ * call made in that context (a script's, inside `archmax_eval`) nests under it
257
+ * rather than under its state. A turn clears it, so a sub-workflow's child —
258
+ * a different session, run inside its caller's delegation call — attaches its
259
+ * own calls to its own states.
260
+ */
261
+ const TOOL_CALL = createContextKey("archmax-harness: inside a tool call of session");
262
+ /**
263
+ * Marks a context as a turn's: the work a turn does — events, calls — finds its
264
+ * own turn through the context it runs in, so two turns open on one session at
265
+ * once (a host invoking it twice) keep their spans apart.
266
+ */
267
+ const TURN = createContextKey("archmax-harness: the turn this work belongs to");
268
+ const TOOL_ERROR_TYPES = {
269
+ error: "tool_error",
270
+ blocked: "blocked",
271
+ unknown: "unknown_tool",
272
+ cancelled: "cancelled",
273
+ };
274
+ /**
275
+ * The runtime's telemetry for one assembly: the spans of every turn its
276
+ * compositions run, a sub-workflow's children included, keyed by session.
277
+ */
278
+ export class Telemetry {
279
+ /**
280
+ * The open turns of each session, oldest first. An ended turn leaves its list
281
+ * at once, wherever it sits in it; a turn the host abandoned without ending
282
+ * (an iterator dropped without `return()`) stays until the process ends.
283
+ */
284
+ #sessions = new Map();
285
+ /** The open turn work of `sessionId` belongs to: the one its context names, else the newest. */
286
+ #turnOf(sessionId) {
287
+ if (!sessionId)
288
+ return undefined;
289
+ const open = this.#sessions.get(sessionId) ?? [];
290
+ const active = context.active().getValue(TURN);
291
+ // Work of a turn that already ended (a stream the host stopped reading) has no
292
+ // turn any more: it never lands in a later turn of the same session.
293
+ if (active?.sessionId === sessionId)
294
+ return open.includes(active) ? active : undefined;
295
+ return open[open.length - 1];
296
+ }
297
+ /**
298
+ * Open a turn's span in the active context. Call {@link TurnHandle.end} or
299
+ * {@link TurnHandle.fail} exactly once.
300
+ */
301
+ openTurn(start) {
302
+ const parent = context.active().deleteValue(TOOL_CALL);
303
+ const dispatchId = currentEventContext()?.subWorkflowDispatchId;
304
+ const span = startSpan(start.workflow ? `invoke_agent ${start.workflow}` : "invoke_agent", {
305
+ kind: SpanKind.INTERNAL,
306
+ attributes: {
307
+ [A.operation]: "invoke_agent",
308
+ ...(start.workflow ? { [A.agentName]: start.workflow } : {}),
309
+ [A.conversationId]: start.sessionId,
310
+ [A.turnResume]: start.resume === true,
311
+ ...(dispatchId ? { [A.dispatchId]: dispatchId } : {}),
312
+ },
313
+ }, parent);
314
+ const record = {
315
+ sessionId: start.sessionId,
316
+ span,
317
+ context: parent,
318
+ visits: 0,
319
+ ...(start.stateType ? { stateType: start.stateType } : {}),
320
+ };
321
+ record.context = trace.setSpan(parent, span).setValue(TURN, record);
322
+ this.#sessions.set(start.sessionId, [...(this.#sessions.get(start.sessionId) ?? []), record]);
323
+ let ended = false;
324
+ const close = () => {
325
+ if (ended)
326
+ return false;
327
+ ended = true;
328
+ const open = (this.#sessions.get(start.sessionId) ?? []).filter((entry) => entry !== record);
329
+ if (open.length > 0)
330
+ this.#sessions.set(start.sessionId, open);
331
+ else
332
+ this.#sessions.delete(start.sessionId);
333
+ safely(() => {
334
+ this.#closeState(record);
335
+ if (record.usage)
336
+ span.setAttributes(usageAttributes(record.usage));
337
+ });
338
+ return true;
339
+ };
340
+ const handle = {
341
+ context: record.context,
342
+ recording: isRecording(span),
343
+ end: (settled = {}) => {
344
+ if (!close())
345
+ return;
346
+ finishSpan(span, () => {
347
+ // A turn that parked and settled with nothing to read (a child session's
348
+ // park, which leaves its turn as a thrown interrupt) reports the park.
349
+ const status = settled.status ?? (record.parked ? `awaiting_${record.parked}` : undefined);
350
+ if (status)
351
+ span.setAttribute(A.sessionStatus, status);
352
+ if (settled.state)
353
+ span.setAttribute(A.state, settled.state);
354
+ if (status === "failed" || status === "rejected") {
355
+ span.setAttribute(A.errorType, `session_${status}`);
356
+ span.setStatus({ code: SpanStatusCode.ERROR });
357
+ }
358
+ });
359
+ },
360
+ fail: (err) => {
361
+ // A park is not a failure: a child session that parks leaves its turn by
362
+ // throwing the interrupt its caller parks on.
363
+ if (isGraphInterrupt(err)) {
364
+ handle.end();
365
+ return;
366
+ }
367
+ if (!close())
368
+ return;
369
+ finishSpan(span, () => {
370
+ span.setAttribute(A.errorType, errorTypeOf(err));
371
+ span.setStatus({ code: SpanStatusCode.ERROR });
372
+ });
373
+ },
374
+ };
375
+ return handle;
376
+ }
377
+ /**
378
+ * Run `fn` as one turn: its span active throughout, settled from what `fn`
379
+ * returned, failed by what it threw (which still propagates).
380
+ */
381
+ async turn(start, fn, settled) {
382
+ const handle = this.openTurn(start);
383
+ let result;
384
+ try {
385
+ result = await context.with(handle.context, fn);
386
+ }
387
+ catch (err) {
388
+ handle.fail(err);
389
+ throw err;
390
+ }
391
+ let outcome = {};
392
+ try {
393
+ outcome = settled(result);
394
+ }
395
+ catch {
396
+ // Reading the outcome is reporting, never the turn's failure.
397
+ }
398
+ handle.end(outcome);
399
+ return result;
400
+ }
401
+ /**
402
+ * Follow the event stream: a state entered opens its span, a state left ends
403
+ * it, and a park, a failed automated run, an `on_error` route and a raise
404
+ * mark it. Events of a session with no open turn are ignored.
405
+ */
406
+ observe(event) {
407
+ const record = this.#turnOf(event.sessionId);
408
+ if (!record)
409
+ return;
410
+ safely(() => this.#follow(record, event));
411
+ }
412
+ #follow(record, event) {
413
+ switch (event.type) {
414
+ case "state-enter":
415
+ this.#openState(record, event.state, false);
416
+ return;
417
+ case "state-leave": {
418
+ const open = record.state;
419
+ if (open?.slug !== event.state)
420
+ return;
421
+ // A session that completes leaves its terminal state for itself: it went nowhere.
422
+ if (event.next !== event.state)
423
+ open.span.setAttribute(A.stateNext, event.next);
424
+ this.#closeState(record);
425
+ return;
426
+ }
427
+ case "parked":
428
+ record.parked = event.awaiting;
429
+ this.#stateSpan(record, event.state)?.setAttribute(A.stateParked, event.awaiting);
430
+ return;
431
+ case "code-result":
432
+ case "grader-result":
433
+ if (event.status === "error")
434
+ this.#failState(record, event.state, "automated_state_failed");
435
+ return;
436
+ case "state-error-routed":
437
+ this.#failState(record, event.state, "routed_on_error");
438
+ this.#stateSpan(record, event.state)?.setAttribute(A.stateNext, event.to);
439
+ return;
440
+ case "raised": {
441
+ this.#failState(record, event.state, "raised");
442
+ const code = reportedRaiseCode(event.code);
443
+ if (code)
444
+ this.#stateSpan(record, event.state)?.setAttribute(A.raiseCode, code);
445
+ return;
446
+ }
447
+ default:
448
+ return;
449
+ }
450
+ }
451
+ /**
452
+ * The context a model or tool call's span is opened in: the active one when
453
+ * it is inside a tool call of the same session (a script's call); else the
454
+ * active one with its span replaced by the turn's — the open span of the state
455
+ * the call is made in; else, when the turn has opened no state yet (it
456
+ * continues in the state the session was in), that state's span, opened now
457
+ * and marked resumed; else the turn's own span (a call of a batch made in a
458
+ * state the batch has already left). A session with no open turn leaves the
459
+ * active context as it is.
460
+ */
461
+ parentFor(sessionId, state) {
462
+ const active = context.active();
463
+ if (active.getValue(TOOL_CALL) === sessionId)
464
+ return active;
465
+ const record = this.#turnOf(sessionId);
466
+ if (!record)
467
+ return active;
468
+ let span = record.span;
469
+ if (state === undefined || record.state?.slug === state) {
470
+ span = record.state?.span ?? record.span;
471
+ }
472
+ else if (record.visits === 0) {
473
+ this.#openState(record, state, true);
474
+ span = record.state?.span ?? record.span;
475
+ }
476
+ return trace.setSpan(active, span).setValue(TURN, record);
477
+ }
478
+ /** Open a model call's span under {@link parentFor}. */
479
+ modelCall(start) {
480
+ const parent = this.parentFor(start.sessionId, start.state);
481
+ const requestModel = reportedModelId(start.requestModel);
482
+ const provider = providerOf(start.model);
483
+ const span = startSpan(requestModel ? `chat ${requestModel}` : "chat", {
484
+ kind: SpanKind.CLIENT,
485
+ attributes: {
486
+ [A.operation]: "chat",
487
+ ...(provider ? { [A.provider]: provider } : {}),
488
+ ...(requestModel ? { [A.requestModel]: requestModel } : {}),
489
+ [A.conversationId]: start.sessionId,
490
+ ...(start.state !== undefined ? { [A.state]: start.state } : {}),
491
+ [A.modelRole]: start.role,
492
+ },
493
+ }, parent);
494
+ const startedAt = performance.now();
495
+ const metricAttributes = {
496
+ [A.operation]: "chat",
497
+ ...(provider ? { [A.provider]: provider } : {}),
498
+ ...(requestModel ? { [A.requestModel]: requestModel } : {}),
499
+ };
500
+ let settled = false;
501
+ const finish = (attributes, error) => {
502
+ if (settled)
503
+ return false;
504
+ settled = true;
505
+ finishSpan(span, () => {
506
+ span.setAttributes(attributes);
507
+ if (error) {
508
+ span.setAttribute(A.errorType, error);
509
+ span.setStatus({ code: SpanStatusCode.ERROR });
510
+ }
511
+ });
512
+ safely(() => metricInstruments().duration.record((performance.now() - startedAt) / 1000, {
513
+ ...metricAttributes,
514
+ ...(attributes[A.responseModel] !== undefined
515
+ ? { [A.responseModel]: attributes[A.responseModel] }
516
+ : {}),
517
+ ...(error ? { [A.errorType]: error } : {}),
518
+ }));
519
+ return true;
520
+ };
521
+ return {
522
+ context: trace.setSpan(parent, span),
523
+ settle: (response) => {
524
+ // Read what the answer reports; an answer that cannot be read still ends the span.
525
+ let attributes = {};
526
+ let summary;
527
+ let responseModel;
528
+ safely(() => {
529
+ const message = responseMessage(response);
530
+ const extracted = extractUsage(message);
531
+ responseModel = reportedModelId(extracted?.model);
532
+ summary = extracted
533
+ ? summarizeUsage(extracted.usage, start.pricing, extracted.model ?? start.pricingModel)
534
+ : undefined;
535
+ const reasons = finishReasonsOf(message);
536
+ attributes = {
537
+ ...(responseModel ? { [A.responseModel]: responseModel } : {}),
538
+ ...(reasons.length > 0 ? { [A.finishReasons]: reasons } : {}),
539
+ [A.responseToolCalls]: toolCallCount(message),
540
+ ...(summary ? usageAttributes(summary) : {}),
541
+ };
542
+ });
543
+ if (!finish(attributes) || !summary)
544
+ return;
545
+ const usage = summary;
546
+ safely(() => {
547
+ const record = this.#turnOf(start.sessionId);
548
+ if (record)
549
+ record.usage = record.usage ? addSummaries(record.usage, usage) : usage;
550
+ const { tokens } = metricInstruments();
551
+ const tokenAttributes = {
552
+ ...metricAttributes,
553
+ ...(responseModel ? { [A.responseModel]: responseModel } : {}),
554
+ };
555
+ tokens.record(usage.inputTokens, { ...tokenAttributes, [A.tokenType]: "input" });
556
+ tokens.record(usage.outputTokens, { ...tokenAttributes, [A.tokenType]: "output" });
557
+ });
558
+ },
559
+ fail: (err) => safely(() => void finish({}, errorTypeOf(err))),
560
+ expire: () => safely(() => void finish({}, "timeout")),
561
+ };
562
+ }
563
+ /** Open a tool call's span under {@link parentFor}, marked as a tool call of its session. */
564
+ toolCall(start) {
565
+ const parent = this.parentFor(start.sessionId, start.state);
566
+ let tool = reportedToolName(start.tool);
567
+ const callId = reportedCallId(start.callId);
568
+ const span = startSpan(`execute_tool ${tool}`, {
569
+ kind: SpanKind.INTERNAL,
570
+ attributes: {
571
+ [A.operation]: "execute_tool",
572
+ [A.toolName]: tool,
573
+ ...(callId ? { [A.toolCallId]: callId } : {}),
574
+ [A.toolType]: "function",
575
+ [A.conversationId]: start.sessionId,
576
+ ...(start.state !== undefined ? { [A.state]: start.state } : {}),
577
+ [A.toolOrigin]: start.origin,
578
+ },
579
+ }, parent);
580
+ let ended = false;
581
+ return {
582
+ context: trace.setSpan(parent, span).setValue(TOOL_CALL, start.sessionId),
583
+ rename: (resolved) => safely(() => {
584
+ const name = reportedToolName(resolved);
585
+ if (ended || name === tool)
586
+ return;
587
+ tool = name;
588
+ span.updateName(`execute_tool ${tool}`);
589
+ span.setAttribute(A.toolName, tool);
590
+ }),
591
+ end: (outcome, rule) => {
592
+ if (ended)
593
+ return;
594
+ ended = true;
595
+ finishSpan(span, () => annotateEnd(outcome, rule));
596
+ },
597
+ };
598
+ function annotateEnd(outcome, rule) {
599
+ if (outcome === "unknown" && tool !== "unknown") {
600
+ // An unknown name is the model's text, not a tool's identity.
601
+ tool = "unknown";
602
+ span.updateName("execute_tool unknown");
603
+ span.setAttribute(A.toolName, tool);
604
+ }
605
+ span.setAttribute(A.toolOutcome, outcome);
606
+ const errorType = TOOL_ERROR_TYPES[outcome];
607
+ if (errorType) {
608
+ span.setAttribute(A.errorType, errorType);
609
+ span.setStatus({ code: SpanStatusCode.ERROR });
610
+ }
611
+ if (outcome === "blocked" && typeof rule === "string" && IDENTIFIER.test(rule)) {
612
+ span.setAttribute(A.toolRule, rule);
613
+ }
614
+ }
615
+ }
616
+ #openState(record, slug, resumed) {
617
+ this.#closeState(record);
618
+ const type = record.stateType?.(slug);
619
+ const span = startSpan(`state ${slug}`, {
620
+ kind: SpanKind.INTERNAL,
621
+ attributes: {
622
+ [A.state]: slug,
623
+ ...(type ? { [A.stateType]: type } : {}),
624
+ ...(resumed ? { [A.stateResumed]: true } : {}),
625
+ },
626
+ }, record.context);
627
+ record.state = { slug, span, context: trace.setSpan(record.context, span) };
628
+ record.visits += 1;
629
+ }
630
+ #closeState(record) {
631
+ const open = record.state;
632
+ if (!open)
633
+ return;
634
+ delete record.state;
635
+ finishSpan(open.span, () => {
636
+ if (open.errorType) {
637
+ open.span.setAttribute(A.errorType, open.errorType);
638
+ open.span.setStatus({ code: SpanStatusCode.ERROR });
639
+ }
640
+ });
641
+ }
642
+ /** The open span of `slug`, when it is the state open now. */
643
+ #stateSpan(record, slug) {
644
+ return record.state?.slug === slug ? record.state.span : undefined;
645
+ }
646
+ /** Mark the open visit of `slug` failed; the first failure names it. */
647
+ #failState(record, slug, errorType) {
648
+ const open = record.state;
649
+ if (open?.slug === slug && !open.errorType)
650
+ open.errorType = errorType;
651
+ }
652
+ }
653
+ /** Whether a tool's answer is the runtime's refusal of the call. */
654
+ export function isBlockedAnswer(answer) {
655
+ const message = settledToolMessage(answer);
656
+ return message?.additional_kwargs?.governance_blocked === true;
657
+ }
658
+ /** Whether a tool's answer is an error the model reads. */
659
+ export function isErrorAnswer(answer) {
660
+ return settledToolMessage(answer)?.status === "error";
661
+ }
662
+ /** The tool message a call settled with: bare, or carried in a command's update. */
663
+ function settledToolMessage(answer) {
664
+ const update = answer?.update;
665
+ if (Array.isArray(update?.messages)) {
666
+ return update.messages.find((m) => m?.tool_call_id !== undefined);
667
+ }
668
+ return answer;
669
+ }
670
+ /**
671
+ * The outcome of a tool call that threw `err`: a park (the session's own, or a
672
+ * sub-workflow's carried up through the call) is not the call's failure, a
673
+ * command to the parent graph is how the call answered, and a throw after the
674
+ * turn was cancelled is the cancellation's.
675
+ */
676
+ export function thrownOutcomeOf(err, signal) {
677
+ if (isGraphInterrupt(err))
678
+ return "parked";
679
+ if (isGraphBubbleUp(err))
680
+ return "ok";
681
+ return signal?.aborted ? "cancelled" : "error";
682
+ }
683
+ /** The outcome of a tool call that settled with `answer`. */
684
+ export function toolOutcomeOf(answer) {
685
+ if (isBlockedAnswer(answer))
686
+ return "blocked";
687
+ if (isParkingAnswer(answer))
688
+ return "parked";
689
+ return isErrorAnswer(answer) ? "error" : "ok";
690
+ }
691
+ /**
692
+ * Whether a tool's answer parks the session: it commits a pending input
693
+ * (`archmax_wait` accepted), a pending decision, or a pending delegation (a
694
+ * sub-workflow that stopped for a person).
695
+ */
696
+ export function isParkingAnswer(answer) {
697
+ const update = answer?.update;
698
+ if (!update || typeof update !== "object")
699
+ return false;
700
+ const delegations = update.pendingDelegations;
701
+ return ((update.pendingInput !== undefined && update.pendingInput !== null) ||
702
+ (update.pendingDecision !== undefined && update.pendingDecision !== null) ||
703
+ (Array.isArray(delegations) && delegations.length > 0));
704
+ }
705
+ //# sourceMappingURL=telemetry.js.map