@noetic-tools/core 1.2.0 → 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.
- package/dist/harness/agent-harness.d.ts +15 -0
- package/dist/harness/agent-harness.js +33 -3
- package/dist/harness/model-call.d.ts +4 -1
- package/dist/harness/model-call.js +94 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/interpreter/execute-action.js +2 -0
- package/dist/observability/genai-attributes.d.ts +12 -0
- package/dist/observability/genai-attributes.js +14 -0
- package/dist/patterns/dynamic-workflow.js +34 -3
- package/dist/schemas/workflow.d.ts +17 -0
- package/dist/schemas/workflow.js +25 -0
- package/package.json +2 -2
|
@@ -105,6 +105,13 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
105
105
|
readonly layerStateStore: LayerStateStore;
|
|
106
106
|
/** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
|
|
107
107
|
readonly recallCache: RecallCache;
|
|
108
|
+
/**
|
|
109
|
+
* Execution ids whose layer `init` hooks have already run, so repeated/nested
|
|
110
|
+
* `run()` calls and the session turn path never re-init (which would clobber
|
|
111
|
+
* accumulated in-memory state by re-hydrating from storage). Keyed by
|
|
112
|
+
* `ctx.id` (the layer-state store's executionId).
|
|
113
|
+
*/
|
|
114
|
+
private readonly initializedExecutions;
|
|
108
115
|
readonly traceExporter: TraceExporter;
|
|
109
116
|
/**
|
|
110
117
|
* Long-lived shared cwd state. The same reference is seeded into every
|
|
@@ -143,6 +150,14 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
143
150
|
/** Run all layer `init` hooks before the first step executes. Per spec 11,
|
|
144
151
|
* init MUST complete before any recall fires so layer state is populated. */
|
|
145
152
|
private initAndRun;
|
|
153
|
+
/**
|
|
154
|
+
* Idempotently run layer `init` hooks for `ctx` (keyed by `ctx.id`). Init
|
|
155
|
+
* MUST complete before any recall/store fires so init-bearing layers are not
|
|
156
|
+
* treated as disabled. Guarded so nested/repeated `run()` calls and the
|
|
157
|
+
* session turn path never re-init — re-init would re-hydrate from storage and
|
|
158
|
+
* clobber accumulated in-memory state.
|
|
159
|
+
*/
|
|
160
|
+
private ensureLayersInit;
|
|
146
161
|
detachedSpawn<I, O>(s: Step<ContextMemory, I, O>, input: I, parentCtx: Context, overrides?: DetachedSpawnOverrides): DetachedHandle<O>;
|
|
147
162
|
createContext(opts?: {
|
|
148
163
|
parent?: Context;
|
|
@@ -110,6 +110,13 @@ export class AgentHarness {
|
|
|
110
110
|
layerStateStore;
|
|
111
111
|
/** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
|
|
112
112
|
recallCache;
|
|
113
|
+
/**
|
|
114
|
+
* Execution ids whose layer `init` hooks have already run, so repeated/nested
|
|
115
|
+
* `run()` calls and the session turn path never re-init (which would clobber
|
|
116
|
+
* accumulated in-memory state by re-hydrating from storage). Keyed by
|
|
117
|
+
* `ctx.id` (the layer-state store's executionId).
|
|
118
|
+
*/
|
|
119
|
+
initializedExecutions = new Set();
|
|
113
120
|
traceExporter;
|
|
114
121
|
/**
|
|
115
122
|
* Long-lived shared cwd state. The same reference is seeded into every
|
|
@@ -342,6 +349,7 @@ export class AgentHarness {
|
|
|
342
349
|
callModelOverride: this.callModelOverride,
|
|
343
350
|
streamIdleTimeoutMs: this.streamIdleTimeoutMs,
|
|
344
351
|
harness: this,
|
|
352
|
+
traceExporter: this.traceExporter,
|
|
345
353
|
}).callModel(request);
|
|
346
354
|
}
|
|
347
355
|
//#endregion
|
|
@@ -356,17 +364,36 @@ export class AgentHarness {
|
|
|
356
364
|
...this.harnessTools,
|
|
357
365
|
]);
|
|
358
366
|
}
|
|
367
|
+
// Hydrate/recall/persist memory on the bare `run()` path too (issue #48):
|
|
368
|
+
// configuring `memory` + `storage` must "just work" without going through
|
|
369
|
+
// `execute()`/`seedSessionHistory`. Idempotent — see `ensureLayersInit`.
|
|
370
|
+
await this.ensureLayersInit(ctx);
|
|
359
371
|
return execute(s, input, ctx);
|
|
360
372
|
}
|
|
361
373
|
/** Run all layer `init` hooks before the first step executes. Per spec 11,
|
|
362
374
|
* init MUST complete before any recall fires so layer state is populated. */
|
|
363
375
|
async initAndRun(s, input, ctx) {
|
|
376
|
+
await this.ensureLayersInit(ctx);
|
|
377
|
+
return execute(s, input, ctx);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Idempotently run layer `init` hooks for `ctx` (keyed by `ctx.id`). Init
|
|
381
|
+
* MUST complete before any recall/store fires so init-bearing layers are not
|
|
382
|
+
* treated as disabled. Guarded so nested/repeated `run()` calls and the
|
|
383
|
+
* session turn path never re-init — re-init would re-hydrate from storage and
|
|
384
|
+
* clobber accumulated in-memory state.
|
|
385
|
+
*/
|
|
386
|
+
async ensureLayersInit(ctx) {
|
|
364
387
|
const layers = ctx.layers;
|
|
365
388
|
const storage = this.config.storage;
|
|
366
|
-
if (layers
|
|
367
|
-
|
|
389
|
+
if (!layers || layers.length === 0 || !storage) {
|
|
390
|
+
return;
|
|
368
391
|
}
|
|
369
|
-
|
|
392
|
+
if (this.initializedExecutions.has(ctx.id)) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.initializedExecutions.add(ctx.id);
|
|
396
|
+
await this.initLayers(layers, ctx, storage);
|
|
370
397
|
}
|
|
371
398
|
detachedSpawn(s, input, parentCtx, overrides) {
|
|
372
399
|
return dispatchStepThroughAdapter(this, s, input, parentCtx, overrides);
|
|
@@ -538,6 +565,9 @@ export class AgentHarness {
|
|
|
538
565
|
});
|
|
539
566
|
}
|
|
540
567
|
async disposeLayers(layers, ctx) {
|
|
568
|
+
// Drop the init guard so a deliberate dispose→run cycle re-hydrates from
|
|
569
|
+
// storage (disposeLayers also clears the layer-state store for this id).
|
|
570
|
+
this.initializedExecutions.delete(ctx.id);
|
|
541
571
|
await disposeLayers({
|
|
542
572
|
layers,
|
|
543
573
|
ctx: this.toExecCtx(ctx),
|
|
@@ -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
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 +=
|
|
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 {
|
|
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
|
|
147
|
-
|
|
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.
|
package/dist/schemas/workflow.js
CHANGED
|
@@ -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.
|
|
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.
|
|
70
|
+
"@noetic-tools/types": "^1.0.2",
|
|
71
71
|
"@openrouter/agent": "^0.6.0",
|
|
72
72
|
"zod": "^4.0.0"
|
|
73
73
|
},
|