@noetic-tools/core 2.1.0 → 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.
@@ -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,97 @@ 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
+ session.push(lastText, (eventType, data) => {
209
+ if (shouldEmit(emit, eventType, data)) {
210
+ emitFrameworkEvent({
211
+ broadcaster,
212
+ agentName,
213
+ eventType,
214
+ data,
215
+ });
216
+ }
217
+ });
218
+ return session.finish(lastText);
219
+ }
220
+ /** JSON-parse + Zod-validate the assistant text, raising `llm_parse_error`. */
221
+ function parseSchemaOutput(schema, lastText, stepId) {
222
+ try {
223
+ return schema.parse(JSON.parse(lastText));
224
+ }
225
+ catch (e) {
226
+ if (e instanceof SyntaxError || e instanceof ZodError) {
227
+ throw new NoeticErrorImpl({
228
+ kind: 'llm_parse_error',
229
+ stepId,
230
+ raw: lastText,
231
+ schema,
232
+ zodError: e instanceof ZodError
233
+ ? e
234
+ : new ZodError([
235
+ {
236
+ code: 'custom',
237
+ message: `Invalid JSON: ${e.message}`,
238
+ path: [],
239
+ },
240
+ ]),
241
+ });
242
+ }
243
+ throw e;
244
+ }
245
+ }
246
+ /**
247
+ * Turn the finished assistant text into the step's typed output: a streaming
248
+ * codec, a Zod schema, or raw text passthrough.
249
+ */
250
+ function finalizeStepOutput(params) {
251
+ if (params.outputCodec) {
252
+ return finalizeCodecOutput(params.outputCodec, params.lastText, params.ctx, params.emit);
253
+ }
254
+ if (params.outputSchema) {
255
+ return parseSchemaOutput(params.outputSchema, params.lastText, params.stepId);
256
+ }
257
+ return frameworkCast(params.lastText);
258
+ }
167
259
  export async function executeLLM(step, input, ctx, layers) {
168
260
  const baseCtx = frameworkCast(ctx);
169
261
  const hasLayers = layers !== undefined && layers.length > 0;
@@ -178,7 +270,8 @@ export async function executeLLM(step, input, ctx, layers) {
178
270
  hint: "Ensure `model` (or your `(ctx) => string` getter) returns a non-empty identifier, e.g. 'anthropic/claude-sonnet-4-20250514'.",
179
271
  });
180
272
  }
181
- const resolvedInstructions = await resolveLazy(step.instructions, ctx);
273
+ const baseInstructions = await resolveLazy(step.instructions, ctx);
274
+ const { outputCodec, outputSchema, resolvedInstructions } = resolveOutputMode(step.output, baseInstructions);
182
275
  const resolvedStepTools = await resolveLazy(step.tools, ctx);
183
276
  // `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
184
277
  // server-tool specs (web search/fetch). Partition them — client tools flow
@@ -309,7 +402,7 @@ export async function executeLLM(step, input, ctx, layers) {
309
402
  instructions: resolvedInstructions,
310
403
  tools: resolvedTools,
311
404
  params: step.params,
312
- outputSchema: step.output,
405
+ outputSchema: outputSchema,
313
406
  emit: step.emit,
314
407
  _serverTools,
315
408
  ctx: baseCtx,
@@ -323,7 +416,7 @@ export async function executeLLM(step, input, ctx, layers) {
323
416
  items: assembledItems,
324
417
  instructions: resolvedInstructions,
325
418
  params: step.params,
326
- outputSchema: step.output,
419
+ outputSchema: outputSchema,
327
420
  emit: step.emit,
328
421
  _serverTools,
329
422
  nodeId: step.id,
@@ -376,33 +469,14 @@ export async function executeLLM(step, input, ctx, layers) {
376
469
  await baseCtx.harness.storeLayers(layers, response, baseCtx);
377
470
  }
378
471
  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);
472
+ return finalizeStepOutput({
473
+ stepId: step.id,
474
+ emit: step.emit,
475
+ outputCodec,
476
+ outputSchema,
477
+ lastText,
478
+ ctx: baseCtx,
479
+ });
406
480
  }
407
481
  // Safety net: the loop above always returns or throws within the body.
408
482
  // This throw is unreachable but protects against future refactors that
@@ -610,21 +684,32 @@ function isAsyncGenerator(value) {
610
684
  async function consumeToolGenerator(params) {
611
685
  const broadcaster = getBroadcaster(params.ctx);
612
686
  const agentName = params.ctx.harness.config.name;
687
+ const events = [];
613
688
  while (true) {
614
689
  const next = await params.generator.next();
615
690
  if (next.done) {
616
691
  return next.value;
617
692
  }
693
+ events.push(next.value);
618
694
  emitFrameworkEvent({
619
695
  broadcaster,
620
696
  agentName,
621
697
  eventType: 'tool_progress',
622
698
  data: {
623
699
  stepId: params.stepId,
624
- toolName: params.toolName,
700
+ toolName: params.tool.name,
625
701
  event: next.value,
626
702
  },
627
703
  });
704
+ // A direct `step.tool` has no model call id — the step id keys its region.
705
+ emitToolUi({
706
+ ctx: params.ctx,
707
+ tool: params.tool,
708
+ callId: params.stepId,
709
+ phase: 'progress',
710
+ args: params.args,
711
+ events,
712
+ });
628
713
  }
629
714
  }
630
715
  export async function executeTool(step, input, ctx, harness, layers) {
@@ -648,20 +733,44 @@ export async function executeTool(step, input, ctx, harness, layers) {
648
733
  });
649
734
  }
650
735
  }
736
+ emitToolUi({
737
+ ctx: baseCtx,
738
+ tool: step.tool,
739
+ callId: step.id,
740
+ phase: 'call',
741
+ args: parseResult.data,
742
+ });
651
743
  try {
652
744
  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,
745
+ const execResult = step.tool.execute(parseResult.data, toolCtx);
746
+ const result = isAsyncGenerator(execResult)
747
+ ? await consumeToolGenerator({
748
+ generator: execResult,
657
749
  stepId: step.id,
658
- toolName: step.tool.name,
750
+ tool: step.tool,
659
751
  ctx: baseCtx,
660
- }));
661
- }
662
- return frameworkCast(await result);
752
+ args: parseResult.data,
753
+ })
754
+ : await execResult;
755
+ emitToolUi({
756
+ ctx: baseCtx,
757
+ tool: step.tool,
758
+ callId: step.id,
759
+ phase: 'result',
760
+ args: parseResult.data,
761
+ output: result,
762
+ });
763
+ return frameworkCast(result);
663
764
  }
664
765
  catch (e) {
766
+ emitToolUi({
767
+ ctx: baseCtx,
768
+ tool: step.tool,
769
+ callId: step.id,
770
+ phase: 'error',
771
+ args: parseResult.data,
772
+ error: e,
773
+ });
665
774
  if (e instanceof NoeticErrorImpl) {
666
775
  throw e;
667
776
  }
@@ -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.0",
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": [