@noetic-tools/core 2.1.0 → 2.2.1

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.
@@ -43,6 +43,8 @@ export interface ExecuteToolCallParams {
43
43
  context: Context;
44
44
  harness: AgentHarnessContract;
45
45
  layers?: MemoryLayer[];
46
+ /** The model's `function_call` id — keys this call's tool-UI region. */
47
+ callId?: string;
46
48
  }
47
49
  /** @internal Execute a single tool call with steering checks. */
48
50
  export declare function executeToolCall(params: ExecuteToolCallParams): Promise<{
@@ -1,6 +1,7 @@
1
1
  import { frameworkCast, isAssistantMessage, NoeticConfigError, SteeringAction, } from '@noetic-tools/types';
2
2
  import { z } from 'zod';
3
3
  import { buildToolExecutionContext } from '../runtime/tool-memory.js';
4
+ import { emitToolUi } from '../runtime/tool-ui.js';
4
5
  const EmbeddingsResponseSchema = z.object({
5
6
  data: z.array(z.object({
6
7
  embedding: z.array(z.number()),
@@ -204,17 +205,6 @@ function isAsyncGenerator(value) {
204
205
  }
205
206
  return Symbol.asyncIterator in value;
206
207
  }
207
- async function consumeGenerator(generator) {
208
- let result;
209
- while (true) {
210
- const next = await generator.next();
211
- if (next.done) {
212
- result = next.value;
213
- break;
214
- }
215
- }
216
- return result;
217
- }
218
208
  /** @internal Execute a single tool call with steering checks. */
219
209
  export async function executeToolCall(params) {
220
210
  // Model sees sanitised tool names (see `sanitizeToolNameForWire`). Match
@@ -245,18 +235,67 @@ export async function executeToolCall(params) {
245
235
  }
246
236
  }
247
237
  const toolCtx = buildToolExecutionContext(params.context, params.harness);
238
+ const callId = params.callId;
239
+ const uiBase = callId !== undefined
240
+ ? {
241
+ ctx: params.context,
242
+ tool: matchedTool,
243
+ callId,
244
+ args: params.args,
245
+ }
246
+ : undefined;
247
+ if (uiBase) {
248
+ emitToolUi({
249
+ ...uiBase,
250
+ phase: 'call',
251
+ });
252
+ }
248
253
  try {
249
254
  const executionResult = matchedTool.execute(params.args, toolCtx);
250
- // Handle generator-based tools (like Bash) that return AsyncGenerator
251
- const result = isAsyncGenerator(executionResult)
252
- ? await consumeGenerator(executionResult)
253
- : await executionResult;
255
+ // Generator tools stream progress; drive them here so tool-UI `progress`
256
+ // fragments emit per yield (the non-UI case just consumes to the return).
257
+ let result;
258
+ if (isAsyncGenerator(executionResult)) {
259
+ const events = [];
260
+ for (;;) {
261
+ const next = await executionResult.next();
262
+ if (next.done) {
263
+ result = next.value;
264
+ break;
265
+ }
266
+ events.push(next.value);
267
+ if (uiBase) {
268
+ emitToolUi({
269
+ ...uiBase,
270
+ phase: 'progress',
271
+ events,
272
+ });
273
+ }
274
+ }
275
+ }
276
+ else {
277
+ result = await executionResult;
278
+ }
279
+ if (uiBase) {
280
+ emitToolUi({
281
+ ...uiBase,
282
+ phase: 'result',
283
+ output: result,
284
+ });
285
+ }
254
286
  return {
255
287
  output: typeof result === 'string' ? result : JSON.stringify(result),
256
288
  result,
257
289
  };
258
290
  }
259
291
  catch (e) {
292
+ if (uiBase) {
293
+ emitToolUi({
294
+ ...uiBase,
295
+ phase: 'error',
296
+ error: e,
297
+ });
298
+ }
260
299
  return {
261
300
  output: `Error: ${e instanceof Error ? e.message : String(e)}`,
262
301
  error: true,
@@ -1,5 +1,5 @@
1
1
  import type { ContextMemory } from '@noetic-tools/memory';
2
- import type { Context, Lazy, ModelParams, RetryPolicy, ServerToolSpec, StepLLM, StepRun, StepSubHarness, StepTool, SubHarness, SubHarnessSessionPolicy, SubHarnessSettings, SubprocessAdapter, Tool } from '@noetic-tools/types';
2
+ import type { Context, Lazy, ModelParams, OutputCodec, RetryPolicy, ServerToolSpec, StepLLM, StepRun, StepSubHarness, StepTool, SubHarness, SubHarnessSessionPolicy, SubHarnessSettings, SubprocessAdapter, Tool } from '@noetic-tools/types';
3
3
  import type { ZodType } from 'zod';
4
4
  interface StepRunOpts<TMemory, I, O> {
5
5
  id: string;
@@ -24,7 +24,8 @@ interface StepLLMOpts<TMemory, O> {
24
24
  * server tool the provider executes, e.g. web search/fetch).
25
25
  */
26
26
  tools?: Lazy<(Tool | ServerToolSpec)[] | undefined, TMemory>;
27
- output?: ZodType<O>;
27
+ /** Structured output: a Zod schema or a streaming `OutputCodec` (e.g. OpenUI Lang). */
28
+ output?: ZodType<O> | OutputCodec<O>;
28
29
  params?: ModelParams;
29
30
  emit?: boolean | ((eventType: string, data: Record<string, unknown>) => boolean);
30
31
  }
@@ -6,7 +6,7 @@
6
6
  * ones — they register in the step registry, carry retry policies, etc.
7
7
  */
8
8
  import type { ContextMemory, MemoryLayer } from '@noetic-tools/memory';
9
- import type { ExecuteStepFn, Step, SubHarness, SubHarnessKind, SubprocessAdapter, Tool } from '@noetic-tools/types';
9
+ import type { ExecuteStepFn, OutputCodec, Step, SubHarness, SubHarnessKind, SubprocessAdapter, Tool } from '@noetic-tools/types';
10
10
  import type { WorkflowDocument, WorkflowNode } from '../schemas/workflow.js';
11
11
  /** @public Context required to hydrate a JSON workflow into live steps. */
12
12
  export interface HydrationContext {
@@ -15,6 +15,13 @@ export interface HydrationContext {
15
15
  layers?: ReadonlyMap<string, MemoryLayer>;
16
16
  /** SubHarness adapters keyed by harness id, resolving `claude-code`/`codex`/… nodes. */
17
17
  subHarnesses?: ReadonlyMap<SubHarnessKind, SubHarness>;
18
+ /**
19
+ * Output codecs keyed by the `library` ref an `llm` node's `output` codec
20
+ * reference names — the live codec built from a component library, e.g.
21
+ * `new Map([['dashboard-lib', openUi(dashboardLibrary)]])` from
22
+ * `@noetic-tools/openui`. Resolved the same way sub-harness adapters are.
23
+ */
24
+ uiLibraries?: ReadonlyMap<string, OutputCodec>;
18
25
  /**
19
26
  * Resolves a named subprocess adapter ref declared on a `run` node's
20
27
  * `subprocess` field. When a node omits the ref, the step falls back to
@@ -49,21 +49,19 @@ function hydrateUntilPredicate(pred) {
49
49
  }
50
50
  //#endregion
51
51
  //#region Node Hydrators
52
- function hydrateLlmNode(node, ctx) {
53
- if (node.kind !== 'llm') {
54
- return frameworkCast(undefined);
55
- }
56
- // Every `tools` entry is an object `{ type, parameters? }`. Partition by the
57
- // `type` value: a reserved `openrouter:*` server-tool literal is a SERVER
58
- // tool (flows through unchanged); any other `type` is a CLIENT tool whose
59
- // `type` is the registered tool NAME (resolved from the registry; a client
60
- // entry's `parameters` is ignored). Both kinds are combined back into one
61
- // `tools` array; the interpreter partitions them again at execution (server
62
- // specs bypass the client-tool machinery and reach the model call's
63
- // server-tool channel).
52
+ /**
53
+ * Resolve an llm node's `tools` entries into a combined client+server list.
54
+ *
55
+ * Every entry is `{ type, parameters? }`. A reserved `openrouter:*` literal is
56
+ * a SERVER tool (flows through unchanged); any other `type` is a CLIENT tool
57
+ * whose `type` is the registered tool NAME (resolved from the registry). Both
58
+ * kinds combine into one `tools` array; the interpreter partitions them again
59
+ * at execution.
60
+ */
61
+ function resolveLlmTools(entries, registry) {
64
62
  const toolNames = [];
65
63
  const serverSpecs = [];
66
- for (const entry of node.tools ?? []) {
64
+ for (const entry of entries ?? []) {
67
65
  if (isServerToolSpec(entry)) {
68
66
  serverSpecs.push(entry);
69
67
  }
@@ -71,17 +69,53 @@ function hydrateLlmNode(node, ctx) {
71
69
  toolNames.push(entry.type);
72
70
  }
73
71
  }
74
- const resolvedTools = resolveTools(toolNames, ctx.tools);
75
- const combined = [
76
- ...resolvedTools,
72
+ return [
73
+ ...resolveTools(toolNames, registry),
77
74
  ...serverSpecs,
78
75
  ];
76
+ }
77
+ function hydrateLlmNode(node, ctx) {
78
+ if (node.kind !== 'llm') {
79
+ return frameworkCast(undefined);
80
+ }
81
+ const combined = resolveLlmTools(node.tools, ctx.tools);
79
82
  return step.llm({
80
83
  id: node.id,
81
84
  model: node.model ?? 'openai/gpt-4o',
82
85
  instructions: node.instructions,
83
86
  tools: combined.length > 0 ? combined : undefined,
84
87
  params: node.params,
88
+ output: resolveOutputCodec(node.output, node.id, ctx),
89
+ });
90
+ }
91
+ /**
92
+ * Resolve an `llm` node's `output` codec reference to a live `OutputCodec`.
93
+ *
94
+ * Hydrated steps are typed `<string, string>` (the JSON boundary erases output
95
+ * types), so the resolved codec is cast to the step's erased output type — the
96
+ * runtime still returns the codec's real value. This is the same erasure the
97
+ * rest of the hydrator applies via `frameworkCast`.
98
+ */
99
+ function availableLibraries(ctx) {
100
+ if (!ctx.uiLibraries || ctx.uiLibraries.size === 0) {
101
+ return '(none)';
102
+ }
103
+ return [
104
+ ...ctx.uiLibraries.keys(),
105
+ ].join(', ');
106
+ }
107
+ function resolveOutputCodec(ref, nodeId, ctx) {
108
+ if (!ref) {
109
+ return undefined;
110
+ }
111
+ const codec = ctx.uiLibraries?.get(ref.library);
112
+ if (codec) {
113
+ return frameworkCast(codec);
114
+ }
115
+ throw new NoeticConfigError({
116
+ code: 'UNKNOWN_UI_LIBRARY_REFERENCE',
117
+ message: `UI library '${ref.library}' referenced in workflow node '${nodeId}' is not registered.`,
118
+ hint: `Pass output codecs via HydrationContext.uiLibraries, e.g. new Map([['${ref.library}', openUi(myLibrary)]]) from @noetic-tools/openui. Available: ${availableLibraries(ctx)}`,
85
119
  });
86
120
  }
87
121
  function hydrateToolNode(node, ctx) {
@@ -685,6 +685,7 @@ export class AgentHarnessModelCaller {
685
685
  context: request.ctx,
686
686
  harness: this.opts.harness,
687
687
  layers: request.layers,
688
+ callId: fc.callId,
688
689
  });
689
690
  // Owner-scoped result validation: a tool's `toolResults` schemas apply
690
691
  // only to that tool's own result items. Harness-level `opts.itemSchemas`
@@ -2,7 +2,7 @@ export type { LayerStateStore } from '@noetic-tools/memory';
2
2
  export { allocateBudgets, assembleView, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/memory';
3
3
  export type { ItemSchemaRegistry } from '@noetic-tools/types';
4
4
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
5
- export { emitFrameworkEvent, getBroadcaster } from '../runtime/broadcaster-utils.js';
5
+ export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
6
6
  export { ContextImpl } from '../runtime/context-impl.js';
7
7
  export { snapshotCwdState } from '../runtime/cwd-helpers.js';
8
8
  export { buildToolExecutionContext } from '../runtime/tool-memory.js';
@@ -1,6 +1,6 @@
1
1
  export { allocateBudgets, assembleView, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/memory';
2
2
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
3
- export { emitFrameworkEvent, getBroadcaster } from '../runtime/broadcaster-utils.js';
3
+ export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
4
4
  export { ContextImpl } from '../runtime/context-impl.js';
5
5
  export { snapshotCwdState } from '../runtime/cwd-helpers.js';
6
6
  export { buildToolExecutionContext } from '../runtime/tool-memory.js';
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Action step handlers: run, llm, spawn, provide, tool.
3
3
  */
4
- import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
4
+ import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, isOutputCodec, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
5
  import { ZodError } from 'zod';
6
- import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, snapshotCwdState, spawnLayers, } from './action-deps.js';
6
+ import { emitToolUi } from '../runtime/tool-ui.js';
7
+ import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, shouldEmit, snapshotCwdState, spawnLayers, } from './action-deps.js';
7
8
  import { isServerToolSpec, SteeringAction } from './action-types.js';
8
9
  import { cloneWithGuard } from './clone-guard.js';
9
10
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
@@ -164,6 +165,102 @@ async function runInputPipeline({ ctx, layers, input, }) {
164
165
  }
165
166
  return rerenderRequests;
166
167
  }
168
+ /**
169
+ * Classify a step's `output` into the JSON-schema vs streaming-codec modes.
170
+ *
171
+ * An `OutputCodec` (e.g. OpenUI Lang) carries its own system-prompt fragment
172
+ * and is NOT a JSON schema — its `instructions` fold into the system prompt and
173
+ * it is kept off `outputSchema` (which drives JSON-schema formatting).
174
+ */
175
+ function resolveOutputMode(output, baseInstructions) {
176
+ // A generic type predicate does not reliably narrow a `ZodType<O> |
177
+ // OutputCodec<O>` union through a free type parameter, so bridge the two
178
+ // branches with the sanctioned framework cast — `isOutputCodec` guarantees
179
+ // the discriminant at runtime.
180
+ const isCodec = isOutputCodec(output);
181
+ const outputCodec = isCodec ? frameworkCast(output) : undefined;
182
+ const outputSchema = isCodec ? undefined : frameworkCast(output);
183
+ const codecInstructions = outputCodec?.instructions;
184
+ const resolvedInstructions = codecInstructions !== undefined
185
+ ? [
186
+ baseInstructions,
187
+ codecInstructions,
188
+ ]
189
+ .filter((s) => s !== undefined)
190
+ .join('\n\n')
191
+ : baseInstructions;
192
+ return {
193
+ outputCodec,
194
+ outputSchema,
195
+ resolvedInstructions,
196
+ };
197
+ }
198
+ /**
199
+ * Drive an `OutputCodec` with the finished assistant text: emit its `openui.*`
200
+ * framework events (gated by `step.emit`) and return the codec's typed value.
201
+ * True per-delta streaming lives in the model caller; here the statements are
202
+ * delivered at turn finalization, which the transport still forwards.
203
+ */
204
+ function finalizeCodecOutput(codec, lastText, ctx, emit) {
205
+ const session = codec.start();
206
+ const broadcaster = getBroadcaster(ctx);
207
+ const agentName = ctx.harness.config.name;
208
+ // A streaming codec emits per completed (newline-terminated) statement, so the
209
+ // final line — usually `root` — would stay buffered and never be emitted.
210
+ // Push a newline-terminated copy so that last statement is flushed as an event
211
+ // too; `finish` still reparses the original text for the returned value.
212
+ const textForEmit = lastText.endsWith('\n') ? lastText : `${lastText}\n`;
213
+ session.push(textForEmit, (eventType, data) => {
214
+ if (shouldEmit(emit, eventType, data)) {
215
+ emitFrameworkEvent({
216
+ broadcaster,
217
+ agentName,
218
+ eventType,
219
+ data,
220
+ });
221
+ }
222
+ });
223
+ return session.finish(lastText);
224
+ }
225
+ /** JSON-parse + Zod-validate the assistant text, raising `llm_parse_error`. */
226
+ function parseSchemaOutput(schema, lastText, stepId) {
227
+ try {
228
+ return schema.parse(JSON.parse(lastText));
229
+ }
230
+ catch (e) {
231
+ if (e instanceof SyntaxError || e instanceof ZodError) {
232
+ throw new NoeticErrorImpl({
233
+ kind: 'llm_parse_error',
234
+ stepId,
235
+ raw: lastText,
236
+ schema,
237
+ zodError: e instanceof ZodError
238
+ ? e
239
+ : new ZodError([
240
+ {
241
+ code: 'custom',
242
+ message: `Invalid JSON: ${e.message}`,
243
+ path: [],
244
+ },
245
+ ]),
246
+ });
247
+ }
248
+ throw e;
249
+ }
250
+ }
251
+ /**
252
+ * Turn the finished assistant text into the step's typed output: a streaming
253
+ * codec, a Zod schema, or raw text passthrough.
254
+ */
255
+ function finalizeStepOutput(params) {
256
+ if (params.outputCodec) {
257
+ return finalizeCodecOutput(params.outputCodec, params.lastText, params.ctx, params.emit);
258
+ }
259
+ if (params.outputSchema) {
260
+ return parseSchemaOutput(params.outputSchema, params.lastText, params.stepId);
261
+ }
262
+ return frameworkCast(params.lastText);
263
+ }
167
264
  export async function executeLLM(step, input, ctx, layers) {
168
265
  const baseCtx = frameworkCast(ctx);
169
266
  const hasLayers = layers !== undefined && layers.length > 0;
@@ -178,7 +275,8 @@ export async function executeLLM(step, input, ctx, layers) {
178
275
  hint: "Ensure `model` (or your `(ctx) => string` getter) returns a non-empty identifier, e.g. 'anthropic/claude-sonnet-4-20250514'.",
179
276
  });
180
277
  }
181
- const resolvedInstructions = await resolveLazy(step.instructions, ctx);
278
+ const baseInstructions = await resolveLazy(step.instructions, ctx);
279
+ const { outputCodec, outputSchema, resolvedInstructions } = resolveOutputMode(step.output, baseInstructions);
182
280
  const resolvedStepTools = await resolveLazy(step.tools, ctx);
183
281
  // `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
184
282
  // server-tool specs (web search/fetch). Partition them — client tools flow
@@ -309,7 +407,7 @@ export async function executeLLM(step, input, ctx, layers) {
309
407
  instructions: resolvedInstructions,
310
408
  tools: resolvedTools,
311
409
  params: step.params,
312
- outputSchema: step.output,
410
+ outputSchema: outputSchema,
313
411
  emit: step.emit,
314
412
  _serverTools,
315
413
  ctx: baseCtx,
@@ -323,7 +421,7 @@ export async function executeLLM(step, input, ctx, layers) {
323
421
  items: assembledItems,
324
422
  instructions: resolvedInstructions,
325
423
  params: step.params,
326
- outputSchema: step.output,
424
+ outputSchema: outputSchema,
327
425
  emit: step.emit,
328
426
  _serverTools,
329
427
  nodeId: step.id,
@@ -376,33 +474,14 @@ export async function executeLLM(step, input, ctx, layers) {
376
474
  await baseCtx.harness.storeLayers(layers, response, baseCtx);
377
475
  }
378
476
  const lastText = extractAssistantText(response.items);
379
- if (step.output) {
380
- try {
381
- const parsed = JSON.parse(lastText);
382
- return step.output.parse(parsed);
383
- }
384
- catch (e) {
385
- if (e instanceof SyntaxError || e instanceof ZodError) {
386
- throw new NoeticErrorImpl({
387
- kind: 'llm_parse_error',
388
- stepId: step.id,
389
- raw: lastText,
390
- schema: step.output,
391
- zodError: e instanceof ZodError
392
- ? e
393
- : new ZodError([
394
- {
395
- code: 'custom',
396
- message: `Invalid JSON: ${e.message}`,
397
- path: [],
398
- },
399
- ]),
400
- });
401
- }
402
- throw e;
403
- }
404
- }
405
- return frameworkCast(lastText);
477
+ return finalizeStepOutput({
478
+ stepId: step.id,
479
+ emit: step.emit,
480
+ outputCodec,
481
+ outputSchema,
482
+ lastText,
483
+ ctx: baseCtx,
484
+ });
406
485
  }
407
486
  // Safety net: the loop above always returns or throws within the body.
408
487
  // This throw is unreachable but protects against future refactors that
@@ -610,21 +689,32 @@ function isAsyncGenerator(value) {
610
689
  async function consumeToolGenerator(params) {
611
690
  const broadcaster = getBroadcaster(params.ctx);
612
691
  const agentName = params.ctx.harness.config.name;
692
+ const events = [];
613
693
  while (true) {
614
694
  const next = await params.generator.next();
615
695
  if (next.done) {
616
696
  return next.value;
617
697
  }
698
+ events.push(next.value);
618
699
  emitFrameworkEvent({
619
700
  broadcaster,
620
701
  agentName,
621
702
  eventType: 'tool_progress',
622
703
  data: {
623
704
  stepId: params.stepId,
624
- toolName: params.toolName,
705
+ toolName: params.tool.name,
625
706
  event: next.value,
626
707
  },
627
708
  });
709
+ // A direct `step.tool` has no model call id — the step id keys its region.
710
+ emitToolUi({
711
+ ctx: params.ctx,
712
+ tool: params.tool,
713
+ callId: params.stepId,
714
+ phase: 'progress',
715
+ args: params.args,
716
+ events,
717
+ });
628
718
  }
629
719
  }
630
720
  export async function executeTool(step, input, ctx, harness, layers) {
@@ -648,20 +738,44 @@ export async function executeTool(step, input, ctx, harness, layers) {
648
738
  });
649
739
  }
650
740
  }
741
+ emitToolUi({
742
+ ctx: baseCtx,
743
+ tool: step.tool,
744
+ callId: step.id,
745
+ phase: 'call',
746
+ args: parseResult.data,
747
+ });
651
748
  try {
652
749
  const toolCtx = buildToolExecutionContext(baseCtx, harness);
653
- const result = step.tool.execute(parseResult.data, toolCtx);
654
- if (isAsyncGenerator(result)) {
655
- return frameworkCast(await consumeToolGenerator({
656
- generator: result,
750
+ const execResult = step.tool.execute(parseResult.data, toolCtx);
751
+ const result = isAsyncGenerator(execResult)
752
+ ? await consumeToolGenerator({
753
+ generator: execResult,
657
754
  stepId: step.id,
658
- toolName: step.tool.name,
755
+ tool: step.tool,
659
756
  ctx: baseCtx,
660
- }));
661
- }
662
- return frameworkCast(await result);
757
+ args: parseResult.data,
758
+ })
759
+ : await execResult;
760
+ emitToolUi({
761
+ ctx: baseCtx,
762
+ tool: step.tool,
763
+ callId: step.id,
764
+ phase: 'result',
765
+ args: parseResult.data,
766
+ output: result,
767
+ });
768
+ return frameworkCast(result);
663
769
  }
664
770
  catch (e) {
771
+ emitToolUi({
772
+ ctx: baseCtx,
773
+ tool: step.tool,
774
+ callId: step.id,
775
+ phase: 'error',
776
+ args: parseResult.data,
777
+ error: e,
778
+ });
665
779
  if (e instanceof NoeticErrorImpl) {
666
780
  throw e;
667
781
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tool-authored UI fragment emission.
3
+ *
4
+ * When a tool declares a `ui` block (`ToolUiDeclaration`), the runtime runs its
5
+ * render functions at the call / progress / result / error lifecycle points and
6
+ * forwards each returned `UiFragment` as an `openui.fragment` framework event
7
+ * (namespaced `${agentName}:openui.fragment`). The framework never interprets
8
+ * the fragment source — a UI surface (memory layer + transport) composes it.
9
+ *
10
+ * The `ui` methods are declared bivariantly on `ToolUiDeclaration`, so the
11
+ * erased `Tool` handle the runtime holds can call them with runtime values.
12
+ */
13
+ import type { Context, Tool } from '@noetic-tools/types';
14
+ /** The tool-UI lifecycle point being rendered. */
15
+ export type ToolUiPhase = 'call' | 'progress' | 'result' | 'error';
16
+ /** Parameters for {@link emitToolUi}. */
17
+ export interface EmitToolUiParams {
18
+ ctx: Context;
19
+ tool: Tool;
20
+ callId: string;
21
+ phase: ToolUiPhase;
22
+ /** Parsed tool args (partial at `call` time). */
23
+ args: unknown;
24
+ /** Events seen so far — for `progress`. */
25
+ events?: unknown[];
26
+ /** The tool's return value — for `result`. */
27
+ output?: unknown;
28
+ /** The thrown error — for `error`. */
29
+ error?: unknown;
30
+ }
31
+ /**
32
+ * Render the tool's UI fragment for one lifecycle phase and, when it produces
33
+ * one, emit it as an `openui.fragment` framework event. No-op when the tool
34
+ * declares no `ui` for that phase.
35
+ */
36
+ export declare function emitToolUi(params: EmitToolUiParams): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tool-authored UI fragment emission.
3
+ *
4
+ * When a tool declares a `ui` block (`ToolUiDeclaration`), the runtime runs its
5
+ * render functions at the call / progress / result / error lifecycle points and
6
+ * forwards each returned `UiFragment` as an `openui.fragment` framework event
7
+ * (namespaced `${agentName}:openui.fragment`). The framework never interprets
8
+ * the fragment source — a UI surface (memory layer + transport) composes it.
9
+ *
10
+ * The `ui` methods are declared bivariantly on `ToolUiDeclaration`, so the
11
+ * erased `Tool` handle the runtime holds can call them with runtime values.
12
+ */
13
+ import { frameworkCast } from '@noetic-tools/types';
14
+ import { emitFrameworkEvent, getBroadcaster } from './broadcaster-utils.js';
15
+ const OPENUI_FRAGMENT_EVENT = 'openui.fragment';
16
+ function renderFragment(params) {
17
+ const ui = params.tool.ui;
18
+ if (!ui) {
19
+ return null;
20
+ }
21
+ // The `ui` methods are typed against the tool's own I/O schemas, erased to
22
+ // `ZodTypeAny` on the `Tool` handle the runtime holds. frameworkCast bridges
23
+ // the runtime values (`unknown`) to those erased parameter types.
24
+ const args = frameworkCast(params.args);
25
+ const output = frameworkCast(params.output);
26
+ switch (params.phase) {
27
+ case 'call':
28
+ return ui.call ? ui.call(args) : null;
29
+ case 'progress':
30
+ return ui.progress ? ui.progress(params.events ?? []) : null;
31
+ case 'result':
32
+ return ui.result ? ui.result(output, args) : null;
33
+ case 'error':
34
+ return ui.error ? ui.error(params.error, args) : null;
35
+ }
36
+ }
37
+ /**
38
+ * Render the tool's UI fragment for one lifecycle phase and, when it produces
39
+ * one, emit it as an `openui.fragment` framework event. No-op when the tool
40
+ * declares no `ui` for that phase.
41
+ */
42
+ export function emitToolUi(params) {
43
+ const fragment = renderFragment(params);
44
+ if (fragment === null) {
45
+ return;
46
+ }
47
+ emitFrameworkEvent({
48
+ broadcaster: getBroadcaster(params.ctx),
49
+ agentName: params.ctx.harness.config.name,
50
+ eventType: OPENUI_FRAGMENT_EVENT,
51
+ data: {
52
+ callId: params.callId,
53
+ dialect: fragment.dialect,
54
+ source: fragment.source,
55
+ },
56
+ });
57
+ }
@@ -114,6 +114,19 @@ declare const HarnessSessionPolicySchema: z.ZodObject<{
114
114
  interface WorkflowNodeBase {
115
115
  id: string;
116
116
  }
117
+ /**
118
+ * A reference to a streaming `OutputCodec` for an `llm` node's structured
119
+ * output. The hydrator resolves `library` from `HydrationContext.uiLibraries`
120
+ * to a live codec (e.g. `openUi(myLibrary)` from `@noetic-tools/openui`), the
121
+ * same registry-resolution pattern sub-harness nodes use for adapters. Kept a
122
+ * *reference* because a codec is a runtime object, not JSON-expressible.
123
+ */
124
+ export interface OutputCodecRef {
125
+ /** The codec dialect. Only `'openui'` is defined today. */
126
+ codec: 'openui';
127
+ /** Registry key resolved from `HydrationContext.uiLibraries`. */
128
+ library: string;
129
+ }
117
130
  export interface LlmWorkflowNode extends WorkflowNodeBase {
118
131
  kind: 'llm';
119
132
  model?: string;
@@ -127,6 +140,8 @@ export interface LlmWorkflowNode extends WorkflowNodeBase {
127
140
  */
128
141
  tools?: z.infer<typeof LlmToolEntrySchema>[];
129
142
  params?: z.infer<typeof ModelParamsSchema>;
143
+ /** Streaming output-codec reference, resolved from `HydrationContext.uiLibraries`. */
144
+ output?: OutputCodecRef;
130
145
  }
131
146
  export interface ToolWorkflowNode extends WorkflowNodeBase {
132
147
  kind: 'tool';
@@ -151,6 +151,10 @@ const WorkflowNodeRef = z.lazy(() => WorkflowNodeSchema);
151
151
  const SHARED_FIELDS = {
152
152
  id: z.string().min(1),
153
153
  };
154
+ const OutputCodecRefSchema = z.object({
155
+ codec: z.literal('openui'),
156
+ library: z.string().min(1),
157
+ });
154
158
  const LlmNodeSchema = z.object({
155
159
  kind: z.literal('llm'),
156
160
  ...SHARED_FIELDS,
@@ -158,6 +162,7 @@ const LlmNodeSchema = z.object({
158
162
  instructions: z.string(),
159
163
  tools: z.array(LlmToolEntrySchema).optional(),
160
164
  params: ModelParamsSchema.optional(),
165
+ output: OutputCodecRefSchema.optional(),
161
166
  });
162
167
  const ToolNodeSchema = z.object({
163
168
  kind: z.literal('tool'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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.2.0",
70
+ "@noetic-tools/types": "^1.3.0",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },
@@ -86,6 +86,24 @@
86
86
  }
87
87
  },
88
88
  "additionalProperties": false
89
+ },
90
+ "output": {
91
+ "type": "object",
92
+ "properties": {
93
+ "codec": {
94
+ "type": "string",
95
+ "const": "openui"
96
+ },
97
+ "library": {
98
+ "type": "string",
99
+ "minLength": 1
100
+ }
101
+ },
102
+ "required": [
103
+ "codec",
104
+ "library"
105
+ ],
106
+ "additionalProperties": false
89
107
  }
90
108
  },
91
109
  "required": [