@noetic-tools/core 1.2.1 → 1.2.2

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.
@@ -349,6 +349,7 @@ export class AgentHarness {
349
349
  callModelOverride: this.callModelOverride,
350
350
  streamIdleTimeoutMs: this.streamIdleTimeoutMs,
351
351
  harness: this,
352
+ traceExporter: this.traceExporter,
352
353
  }).callModel(request);
353
354
  }
354
355
  //#endregion
@@ -1,4 +1,4 @@
1
- import type { AgentHarnessContract, CallModelRequest, ItemSchemaRegistry, LLMResponse } from '@noetic-tools/types';
1
+ import type { AgentHarnessContract, CallModelRequest, ItemSchemaRegistry, LLMResponse, TraceExporter } from '@noetic-tools/types';
2
2
  import type { OpenRouter } from '@openrouter/agent';
3
3
  import type { MessageQueue } from '../runtime/message-queue.js';
4
4
  interface StreamIdleWatchdog {
@@ -28,13 +28,16 @@ interface AgentHarnessModelCallerOpts {
28
28
  callModelOverride?: (request: CallModelRequest) => Promise<LLMResponse>;
29
29
  streamIdleTimeoutMs: number;
30
30
  harness: AgentHarnessContract;
31
+ traceExporter: TraceExporter;
31
32
  }
32
33
  export declare class AgentHarnessModelCaller {
33
34
  private readonly opts;
34
35
  constructor(opts: AgentHarnessModelCallerOpts);
36
+ private newSpanCollector;
35
37
  private callOverriddenModel;
36
38
  private requireModelClient;
37
39
  callModel(request: CallModelRequest): Promise<LLMResponse>;
40
+ private runModelLoop;
38
41
  private injectBetweenRoundMessages;
39
42
  private handleModelRecovery;
40
43
  private handleEmptyOutputRecovery;
@@ -2,6 +2,7 @@ import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
2
2
  import { z } from 'zod';
3
3
  import { convertTools, executeToolCall, extractOutputItems, extractSystemInstruction, extractUsage, itemsToInput, sanitizeToolNameForWire, } from '../adapters/openrouter.js';
4
4
  import { isFunctionCall } from '../interpreter/typeguards.js';
5
+ import { GenAI, ToolAttr } from '../observability/genai-attributes.js';
5
6
  import { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
6
7
  import { buildItemSchemaRegistry, createToolResultItem } from './model-schema.js';
7
8
  const MAX_TOOL_ROUNDS = 32;
@@ -219,25 +220,85 @@ function filterAllowedTools(request) {
219
220
  const allowedNames = new Set(request.allowedToolNames);
220
221
  return request.tools.filter((tool) => allowedNames.has(tool.name));
221
222
  }
223
+ /**
224
+ * Per-`callModel` span accumulator. Spans are created lazily from the calling
225
+ * context's span (so the trace tree mirrors the execution tree) and flushed to
226
+ * the harness `traceExporter` once the call settles — see issue #50, where the
227
+ * exporter was stored but never invoked.
228
+ */
229
+ class CallSpanCollector {
230
+ harness;
231
+ exporter;
232
+ parent;
233
+ spans = [];
234
+ constructor(harness, exporter, parent) {
235
+ this.harness = harness;
236
+ this.exporter = exporter;
237
+ this.parent = parent;
238
+ }
239
+ /** Open a model-call span stamped with GenAI semantic-convention attributes. */
240
+ startModelSpan(model) {
241
+ const span = this.harness.createSpan('llm.call', this.parent);
242
+ span.setAttribute(GenAI.SYSTEM, 'openrouter');
243
+ span.setAttribute(GenAI.REQUEST_MODEL, model);
244
+ this.spans.push(span);
245
+ return span;
246
+ }
247
+ /** Record usage + cost on a model-call span. */
248
+ finishModelSpan(span, usage, cost) {
249
+ span.setAttribute(GenAI.USAGE_INPUT_TOKENS, usage.inputTokens);
250
+ span.setAttribute(GenAI.USAGE_OUTPUT_TOKENS, usage.outputTokens);
251
+ if (cost !== undefined) {
252
+ span.setAttribute(GenAI.COST, cost);
253
+ }
254
+ span.end();
255
+ }
256
+ /** Open and immediately close a tool-call span under the given model span. */
257
+ recordToolSpan(parent, toolName, needsApproval) {
258
+ const span = this.harness.createSpan('tool.call', parent);
259
+ span.setAttribute(ToolAttr.NAME, toolName);
260
+ span.setAttribute(ToolAttr.NEEDS_APPROVAL, needsApproval);
261
+ span.end();
262
+ this.spans.push(span);
263
+ }
264
+ /** Flush all accumulated spans to the exporter. */
265
+ async flush() {
266
+ if (this.spans.length === 0) {
267
+ return;
268
+ }
269
+ await this.exporter.export(this.spans);
270
+ }
271
+ }
222
272
  export class AgentHarnessModelCaller {
223
273
  opts;
224
274
  constructor(opts) {
225
275
  this.opts = opts;
226
276
  }
277
+ newSpanCollector(request) {
278
+ return new CallSpanCollector(this.opts.harness, this.opts.traceExporter, request.parentSpan ?? request.ctx?.span ?? null);
279
+ }
227
280
  async callOverriddenModel(request) {
228
281
  if (!this.opts.callModelOverride) {
229
282
  throw new Error('No callModel override configured.');
230
283
  }
231
- const response = await this.opts.callModelOverride(request);
232
- const itemSchemas = buildItemSchemaRegistry({
233
- base: this.opts.itemSchemas,
234
- layers: request.layers,
235
- tools: request.tools,
236
- });
237
- return {
238
- ...response,
239
- items: itemSchemas.parseMany(response.items),
240
- };
284
+ const spans = this.newSpanCollector(request);
285
+ const modelSpan = spans.startModelSpan(request.model);
286
+ try {
287
+ const response = await this.opts.callModelOverride(request);
288
+ const itemSchemas = buildItemSchemaRegistry({
289
+ base: this.opts.itemSchemas,
290
+ layers: request.layers,
291
+ tools: request.tools,
292
+ });
293
+ spans.finishModelSpan(modelSpan, response.usage, response.cost);
294
+ return {
295
+ ...response,
296
+ items: itemSchemas.parseMany(response.items),
297
+ };
298
+ }
299
+ finally {
300
+ await spans.flush();
301
+ }
241
302
  }
242
303
  requireModelClient() {
243
304
  if (this.opts.client) {
@@ -254,6 +315,15 @@ export class AgentHarnessModelCaller {
254
315
  return this.callOverriddenModel(request);
255
316
  }
256
317
  const client = this.requireModelClient();
318
+ const spans = this.newSpanCollector(request);
319
+ try {
320
+ return await this.runModelLoop(request, client, spans);
321
+ }
322
+ finally {
323
+ await spans.flush();
324
+ }
325
+ }
326
+ async runModelLoop(request, client, spans) {
257
327
  const prepared = prepareModelRequest(request, this.opts.agentName);
258
328
  const allItems = [];
259
329
  const totalUsage = {
@@ -299,6 +369,7 @@ export class AgentHarnessModelCaller {
299
369
  toolCount: prepared.sdkTools?.length ?? 0,
300
370
  recoveryContinuation,
301
371
  });
372
+ const modelSpan = spans.startModelSpan(request.model);
302
373
  const callResult = client.callModel({
303
374
  model: request.model,
304
375
  input: modelInput,
@@ -360,6 +431,7 @@ export class AgentHarnessModelCaller {
360
431
  emitIfAllowed: prepared.emitIfAllowed,
361
432
  });
362
433
  if (recovery.kind === 'continue') {
434
+ modelSpan.end();
363
435
  invalidRecoveryContinuations = recovery.invalidRecoveryContinuations;
364
436
  useEphemeralContinue = true;
365
437
  continue;
@@ -378,6 +450,7 @@ export class AgentHarnessModelCaller {
378
450
  emitIfAllowed: prepared.emitIfAllowed,
379
451
  });
380
452
  if (outputRecovery.kind === 'continue') {
453
+ modelSpan.end();
381
454
  invalidRecoveryContinuations = outputRecovery.invalidRecoveryContinuations;
382
455
  useEphemeralContinue = true;
383
456
  continue;
@@ -388,10 +461,15 @@ export class AgentHarnessModelCaller {
388
461
  itemCount: sdkResponse.output?.length ?? 0,
389
462
  });
390
463
  const roundUsage = extractUsage(sdkResponse.usage);
464
+ const roundCost = sdkResponse.usage?.cost ?? 0;
465
+ spans.finishModelSpan(modelSpan, {
466
+ inputTokens: roundUsage.inputTokens,
467
+ outputTokens: roundUsage.outputTokens,
468
+ }, roundCost > 0 ? roundCost : undefined);
391
469
  totalUsage.inputTokens += roundUsage.inputTokens;
392
470
  totalUsage.outputTokens += roundUsage.outputTokens;
393
471
  totalUsage.cachedTokens += roundUsage.cachedTokens ?? 0;
394
- totalCost += sdkResponse.usage?.cost ?? 0;
472
+ totalCost += roundCost;
395
473
  allItems.push(...roundItems);
396
474
  const functionCalls = roundItems.filter(isFunctionCall);
397
475
  if (functionCalls.length === 0 || !request.tools) {
@@ -404,6 +482,8 @@ export class AgentHarnessModelCaller {
404
482
  conversationInput,
405
483
  round,
406
484
  emitIfAllowed: prepared.emitIfAllowed,
485
+ spans,
486
+ modelSpan,
407
487
  });
408
488
  round += 1;
409
489
  const limitRecovery = this.handleToolRoundLimit({
@@ -516,7 +596,7 @@ export class AgentHarnessModelCaller {
516
596
  };
517
597
  }
518
598
  async executeToolRound(params) {
519
- const { functionCalls, request, allItems, conversationInput } = params;
599
+ const { functionCalls, request, allItems, conversationInput, spans, modelSpan } = params;
520
600
  params.emitIfAllowed('tool_round_started', {
521
601
  round: params.round,
522
602
  toolCount: functionCalls.length,
@@ -534,6 +614,8 @@ export class AgentHarnessModelCaller {
534
614
  if (request.signal?.aborted) {
535
615
  break;
536
616
  }
617
+ const toolForCall = request.tools?.find((tool) => tool.name === fc.name || sanitizeToolNameForWire(tool.name) === fc.name);
618
+ spans.recordToolSpan(modelSpan, fc.name, toolForCall?.needsApproval ?? false);
537
619
  await this.executeFunctionCall({
538
620
  fc,
539
621
  request,
package/dist/index.d.ts CHANGED
@@ -72,7 +72,7 @@ export { durableTaskState, fileReference, findFunctionCall, historyWindow, obser
72
72
  /** @public */
73
73
  export { createMessage, estimateTokens } from '@noetic-tools/types';
74
74
  /** @public */
75
- export { GenAI, ToolAttr } from './observability/genai-attributes.js';
75
+ export { GenAI, NoeticAttr, ToolAttr } from './observability/genai-attributes.js';
76
76
  /** @public */
77
77
  export { InMemoryExporter, NoopExporter } from './observability/trace-exporter.js';
78
78
  /** @public */
package/dist/index.js CHANGED
@@ -66,7 +66,7 @@ export { createMessage, estimateTokens } from '@noetic-tools/types';
66
66
  //#endregion
67
67
  //#region Observability
68
68
  /** @public */
69
- export { GenAI, ToolAttr } from './observability/genai-attributes.js';
69
+ export { GenAI, NoeticAttr, ToolAttr } from './observability/genai-attributes.js';
70
70
  /** @public */
71
71
  export { InMemoryExporter, NoopExporter } from './observability/trace-exporter.js';
72
72
  /** @public */
@@ -293,6 +293,7 @@ export async function executeLLM(step, input, ctx, layers) {
293
293
  ctx: baseCtx,
294
294
  layers,
295
295
  allowedToolNames,
296
+ parentSpan: baseCtx.span,
296
297
  }
297
298
  : {
298
299
  model: resolvedModel,
@@ -301,6 +302,7 @@ export async function executeLLM(step, input, ctx, layers) {
301
302
  params: step.params,
302
303
  outputSchema: step.output,
303
304
  emit: step.emit,
305
+ parentSpan: baseCtx.span,
304
306
  };
305
307
  const response = await baseCtx.harness.callModel(request);
306
308
  // Track tokens/cost for EVERY model call, including responses steering
@@ -9,3 +9,15 @@ export declare const ToolAttr: {
9
9
  readonly NAME: "tool.name";
10
10
  readonly NEEDS_APPROVAL: "tool.needs_approval";
11
11
  };
12
+ export declare const NoeticAttr: {
13
+ /** Full JSON-serialised `WorkflowDocument` (the DAG, lossless). */
14
+ readonly WORKFLOW_DOCUMENT: "noetic.workflow.document";
15
+ /** Workflow document schema version. */
16
+ readonly WORKFLOW_VERSION: "noetic.workflow.version";
17
+ /** Count of declared nodes in the workflow tree. */
18
+ readonly WORKFLOW_NODE_COUNT: "noetic.workflow.node_count";
19
+ /** JSON array of `{ id, kind }` for every declared node (flattened graph). */
20
+ readonly WORKFLOW_NODES: "noetic.workflow.nodes";
21
+ /** JSON array of `{ from, to }` parent→child edges between declared nodes. */
22
+ readonly WORKFLOW_EDGES: "noetic.workflow.edges";
23
+ };
@@ -10,3 +10,17 @@ export const ToolAttr = {
10
10
  NAME: 'tool.name',
11
11
  NEEDS_APPROVAL: 'tool.needs_approval',
12
12
  };
13
+ // Noetic-specific span attributes describing the static workflow graph (the
14
+ // "potential paths" of the DAG) carried on the root `workflow.run` span.
15
+ export const NoeticAttr = {
16
+ /** Full JSON-serialised `WorkflowDocument` (the DAG, lossless). */
17
+ WORKFLOW_DOCUMENT: 'noetic.workflow.document',
18
+ /** Workflow document schema version. */
19
+ WORKFLOW_VERSION: 'noetic.workflow.version',
20
+ /** Count of declared nodes in the workflow tree. */
21
+ WORKFLOW_NODE_COUNT: 'noetic.workflow.node_count',
22
+ /** JSON array of `{ id, kind }` for every declared node (flattened graph). */
23
+ WORKFLOW_NODES: 'noetic.workflow.nodes',
24
+ /** JSON array of `{ from, to }` parent→child edges between declared nodes. */
25
+ WORKFLOW_EDGES: 'noetic.workflow.edges',
26
+ };
@@ -5,7 +5,8 @@
5
5
  import { frameworkCast, NoeticConfigError } from '@noetic-tools/types';
6
6
  import { step } from '../builders/step-builders.js';
7
7
  import { hydrateWorkflow } from '../builders/workflow-hydrator.js';
8
- import { WorkflowDocumentSchema, workflowDepth } from '../schemas/workflow.js';
8
+ import { NoeticAttr } from '../observability/genai-attributes.js';
9
+ import { WorkflowDocumentSchema, workflowDepth, workflowGraph } from '../schemas/workflow.js';
9
10
  //#endregion
10
11
  //#region Constants
11
12
  const DEFAULT_MAX_DEPTH = 5;
@@ -143,8 +144,38 @@ export async function parseAndRunWorkflow(opts) {
143
144
  executeStep,
144
145
  };
145
146
  const hydrated = hydrateWorkflow(parseResult.doc, hydrationCtx);
146
- const result = await executeStep(hydrated, opts.input ?? '', opts.ctx);
147
- return coerceToString(result);
147
+ const runSpan = beginWorkflowRunSpan(opts.harness, opts.ctx, parseResult.doc);
148
+ try {
149
+ const result = await executeStep(hydrated, opts.input ?? '', opts.ctx);
150
+ return coerceToString(result);
151
+ }
152
+ finally {
153
+ runSpan.end();
154
+ await opts.harness.traceExporter.export([
155
+ runSpan,
156
+ ]);
157
+ }
158
+ }
159
+ /**
160
+ * Open the root `workflow.run` span and stamp the static DAG onto it (document,
161
+ * version, node/edge graph). The span is installed on `ctx.span` so the LLM
162
+ * step's model/tool spans nest under it — the trace tree then mirrors the
163
+ * declared workflow graph, with the executed path overlaid (issue #50 follow-up).
164
+ */
165
+ function beginWorkflowRunSpan(harness, ctx, doc) {
166
+ const span = harness.createSpan('workflow.run', null);
167
+ const graph = workflowGraph(doc.root);
168
+ span.setAttribute(NoeticAttr.WORKFLOW_DOCUMENT, JSON.stringify(doc));
169
+ span.setAttribute(NoeticAttr.WORKFLOW_VERSION, doc.version);
170
+ span.setAttribute(NoeticAttr.WORKFLOW_NODE_COUNT, graph.nodes.length);
171
+ span.setAttribute(NoeticAttr.WORKFLOW_NODES, JSON.stringify(graph.nodes));
172
+ span.setAttribute(NoeticAttr.WORKFLOW_EDGES, JSON.stringify(graph.edges));
173
+ installRunSpan(ctx, span);
174
+ return span;
175
+ }
176
+ /** Install the run span as `ctx.span` so descendant model calls parent under it. */
177
+ function installRunSpan(ctx, span) {
178
+ frameworkCast(ctx).span = span;
148
179
  }
149
180
  function buildToolMap(tools) {
150
181
  return new Map(tools.map((t) => [
@@ -164,6 +164,23 @@ export declare const WorkflowDocumentSchema: z.ZodType<WorkflowDocument>;
164
164
  export declare function validateWorkflow(input: unknown): WorkflowDocument;
165
165
  /** Walks a workflow tree depth-first, yielding each node. */
166
166
  export declare function walkWorkflow(node: WorkflowNode): Iterable<WorkflowNode>;
167
+ /** A flattened, JSON-safe view of a workflow tree: its nodes and parent→child edges. */
168
+ export interface WorkflowGraph {
169
+ nodes: Array<{
170
+ id: string;
171
+ kind: WorkflowNode['kind'];
172
+ }>;
173
+ edges: Array<{
174
+ from: string;
175
+ to: string;
176
+ }>;
177
+ }
178
+ /**
179
+ * Flattens a workflow tree into a node + edge list — the static "potential
180
+ * paths" of the DAG, suitable for attaching to a trace span so observers can
181
+ * reconstruct the declared graph independent of which branches actually ran.
182
+ */
183
+ export declare function workflowGraph(root: WorkflowNode): WorkflowGraph;
167
184
  /**
168
185
  * Returns the maximum depth of a workflow tree.
169
186
  * Leaf nodes (`llm`, `tool`) have depth 0. Structural nodes add +1.
@@ -279,6 +279,31 @@ export function* walkWorkflow(node) {
279
279
  yield* walkWorkflow(child);
280
280
  }
281
281
  }
282
+ /**
283
+ * Flattens a workflow tree into a node + edge list — the static "potential
284
+ * paths" of the DAG, suitable for attaching to a trace span so observers can
285
+ * reconstruct the declared graph independent of which branches actually ran.
286
+ */
287
+ export function workflowGraph(root) {
288
+ const nodes = [];
289
+ const edges = [];
290
+ for (const node of walkWorkflow(root)) {
291
+ nodes.push({
292
+ id: node.id,
293
+ kind: node.kind,
294
+ });
295
+ for (const child of childNodes(node)) {
296
+ edges.push({
297
+ from: node.id,
298
+ to: child.id,
299
+ });
300
+ }
301
+ }
302
+ return {
303
+ nodes,
304
+ edges,
305
+ };
306
+ }
282
307
  /**
283
308
  * Returns the maximum depth of a workflow tree.
284
309
  * Leaf nodes (`llm`, `tool`) have depth 0. Structural nodes add +1.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and memory layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@noetic-tools/memory": "^1.0.1",
70
- "@noetic-tools/types": "^1.0.1",
70
+ "@noetic-tools/types": "^1.0.2",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },