@noetic-tools/core 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +8 -6
  2. package/dist/adapters/in-memory-subprocess/step-completion.js +1 -1
  3. package/dist/adapters/openrouter.d.ts +20 -2
  4. package/dist/adapters/openrouter.js +102 -6
  5. package/dist/builders/control-flow-builders.d.ts +13 -13
  6. package/dist/builders/control-flow-builders.js +11 -11
  7. package/dist/{patterns → builders}/dynamic-workflow.d.ts +1 -1
  8. package/dist/{patterns → builders}/dynamic-workflow.js +18 -12
  9. package/dist/builders/every.d.ts +14 -14
  10. package/dist/builders/every.js +16 -16
  11. package/dist/builders/layer-provides-builders.d.ts +1 -1
  12. package/dist/builders/layer-provides-builders.js +1 -1
  13. package/dist/builders/provide-builder.d.ts +5 -12
  14. package/dist/builders/provide-builder.js +9 -10
  15. package/dist/builders/spawn-builder.d.ts +0 -2
  16. package/dist/builders/spawn-builder.js +2 -3
  17. package/dist/builders/step-builders.d.ts +66 -55
  18. package/dist/builders/step-builders.js +118 -111
  19. package/dist/builders/tool-builder.d.ts +18 -22
  20. package/dist/builders/tool-builder.js +6 -4
  21. package/dist/builders/workflow-hydrator.d.ts +3 -3
  22. package/dist/builders/workflow-hydrator.js +59 -63
  23. package/dist/builders/workflow-step.d.ts +13 -25
  24. package/dist/builders/workflow-step.js +13 -18
  25. package/dist/conditions/conditions.d.ts +2 -2
  26. package/dist/conditions/conditions.js +1 -1
  27. package/dist/harness/agent-harness.d.ts +58 -40
  28. package/dist/harness/agent-harness.js +125 -51
  29. package/dist/harness/deps/context.d.ts +1 -1
  30. package/dist/harness/deps/context.js +1 -1
  31. package/dist/harness/deps/interpreter.d.ts +1 -0
  32. package/dist/harness/deps/interpreter.js +1 -0
  33. package/dist/harness/deps/types.d.ts +2 -2
  34. package/dist/harness/model-call.d.ts +1 -1
  35. package/dist/harness/model-call.js +45 -23
  36. package/dist/index.d.ts +23 -35
  37. package/dist/index.js +10 -26
  38. package/dist/interpreter/action-deps.d.ts +2 -2
  39. package/dist/interpreter/action-deps.js +1 -1
  40. package/dist/interpreter/action-types.d.ts +1 -1
  41. package/dist/interpreter/collect-tools.js +7 -7
  42. package/dist/interpreter/context-assembly.d.ts +50 -0
  43. package/dist/interpreter/context-assembly.js +547 -0
  44. package/dist/interpreter/execute-action.d.ts +5 -5
  45. package/dist/interpreter/execute-action.js +337 -208
  46. package/dist/interpreter/execute-control.d.ts +9 -9
  47. package/dist/interpreter/execute-control.js +27 -27
  48. package/dist/interpreter/execute-sub-harness.d.ts +1 -1
  49. package/dist/interpreter/execute-sub-harness.js +7 -26
  50. package/dist/interpreter/execute.d.ts +1 -1
  51. package/dist/interpreter/execute.js +26 -25
  52. package/dist/interpreter/step-dispatcher.d.ts +1 -1
  53. package/dist/interpreter/step-dispatcher.js +3 -3
  54. package/dist/interpreter/structured-output.d.ts +14 -0
  55. package/dist/interpreter/structured-output.js +40 -0
  56. package/dist/interpreter/typeguards.d.ts +1 -1
  57. package/dist/interpreter/typeguards.js +1 -1
  58. package/dist/observability/genai-attributes.d.ts +20 -0
  59. package/dist/observability/genai-attributes.js +20 -0
  60. package/dist/portable/builders.d.ts +3 -3
  61. package/dist/portable/builders.js +6 -5
  62. package/dist/portable/context-layers.d.ts +1 -1
  63. package/dist/portable/context-layers.js +1 -1
  64. package/dist/runtime/broadcaster-utils.d.ts +1 -1
  65. package/dist/runtime/broadcaster-utils.js +1 -1
  66. package/dist/runtime/context-impl.d.ts +8 -10
  67. package/dist/runtime/context-impl.js +8 -12
  68. package/dist/runtime/durable/step-ledger.d.ts +2 -2
  69. package/dist/runtime/durable/step-ledger.js +3 -3
  70. package/dist/runtime/session-runner.d.ts +12 -1
  71. package/dist/runtime/session-runner.js +33 -1
  72. package/dist/runtime/tool-context.js +0 -2
  73. package/dist/schemas/workflow.d.ts +3 -3
  74. package/dist/schemas/workflow.js +2 -2
  75. package/dist/types/checkpoint.d.ts +11 -2
  76. package/dist/types/checkpoint.js +10 -1
  77. package/dist/{runtime → util}/detached-handle.js +1 -1
  78. package/package.json +6 -4
  79. package/schema/noetic-workflow.schema.json +24 -11
  80. package/dist/builders/context-option.d.ts +0 -23
  81. package/dist/builders/context-option.js +0 -22
  82. package/dist/deprecated.d.ts +0 -30
  83. package/dist/deprecated.js +0 -28
  84. package/dist/patterns/interview.d.ts +0 -53
  85. package/dist/patterns/interview.js +0 -113
  86. package/dist/patterns/plans.d.ts +0 -44
  87. package/dist/patterns/plans.js +0 -125
  88. package/dist/patterns/ralph-wiggum.d.ts +0 -19
  89. package/dist/patterns/ralph-wiggum.js +0 -37
  90. package/dist/patterns/react.d.ts +0 -19
  91. package/dist/patterns/react.js +0 -41
  92. /package/dist/{runtime → util}/detached-handle.d.ts +0 -0
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Action step handlers: run, llm, spawn, provide, tool.
3
3
  */
4
- import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, isOutputCodec, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
- import { ZodError } from 'zod';
4
+ import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, isOutputCodec, NoeticConfigError, NoeticErrorImpl, validateSchema, } from '@noetic-tools/types';
6
5
  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';
6
+ import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, createContextCacheStore, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, lineageKey, noteCacheOutcome, resolveCacheConfig, resolveLayerTools, returnLayers, shouldEmit, snapshotCwdState, spawnLayers, } from './action-deps.js';
8
7
  import { isServerToolSpec, SteeringAction } from './action-types.js';
9
8
  import { cloneWithGuard } from './clone-guard.js';
10
9
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
10
+ import { prepareBandedView, stampAnchoringAttributes } from './context-assembly.js';
11
11
  import { trackUsage } from './message-helpers.js';
12
+ import { parseStructuredOutput } from './structured-output.js';
12
13
  import { getContextChannelStore, isContextImpl, isFunctionCall, isMutableContext, } from './typeguards.js';
13
14
  //#region run
14
- export async function executeRun(step, input, ctx) {
15
+ export async function executeRunCode(step, input, ctx) {
15
16
  const retry = step.retry;
16
17
  const maxAttempts = retry?.maxAttempts ?? 1;
17
18
  let lastError = new Error('No attempts executed');
@@ -173,7 +174,7 @@ async function runInputPipeline({ ctx, layers, input, }) {
173
174
  * it is kept off `outputSchema` (which drives JSON-schema formatting).
174
175
  */
175
176
  function resolveOutputMode(output, baseInstructions) {
176
- // A generic type predicate does not reliably narrow a `ZodType<O> |
177
+ // A generic type predicate does not reliably narrow a `StandardSchemaV1 |
177
178
  // OutputCodec<O>` union through a free type parameter, so bridge the two
178
179
  // branches with the sanctioned framework cast — `isOutputCodec` guarantees
179
180
  // the discriminant at runtime.
@@ -222,46 +223,237 @@ function finalizeCodecOutput(codec, lastText, ctx, emit) {
222
223
  });
223
224
  return session.finish(lastText);
224
225
  }
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
226
  /**
252
227
  * Turn the finished assistant text into the step's typed output: a streaming
253
- * codec, a Zod schema, or raw text passthrough.
228
+ * codec, a Standard Schema, or raw text passthrough.
254
229
  */
255
- function finalizeStepOutput(params) {
230
+ async function finalizeStepOutput(params) {
256
231
  if (params.outputCodec) {
257
232
  return finalizeCodecOutput(params.outputCodec, params.lastText, params.ctx, params.emit);
258
233
  }
259
234
  if (params.outputSchema) {
260
- return parseSchemaOutput(params.outputSchema, params.lastText, params.stepId);
235
+ return parseStructuredOutput({
236
+ schema: params.outputSchema,
237
+ rawText: params.lastText,
238
+ stepId: params.stepId,
239
+ });
261
240
  }
262
241
  return frameworkCast(params.lastText);
263
242
  }
264
- export async function executeLLM(step, input, ctx, layers) {
243
+ /**
244
+ * `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
245
+ * server-tool specs (web search/fetch). Partition them — client tools flow
246
+ * through the unified-tool / allowedToolNames / steering machinery as before;
247
+ * server-tool specs bypass it entirely and are stamped onto the model request
248
+ * (`_serverTools`) for the model caller to wrap via the SDK's `serverTool()`.
249
+ * `undefined` (unrestricted) is preserved as-is; only a present array is
250
+ * partitioned so the `[] = opt-out` semantics of step.tools stay intact.
251
+ */
252
+ function partitionStepTools(resolvedStepTools) {
253
+ if (resolvedStepTools === undefined) {
254
+ return {
255
+ clientStepTools: undefined,
256
+ serverToolSpecs: [],
257
+ };
258
+ }
259
+ const clientStepTools = [];
260
+ const serverToolSpecs = [];
261
+ for (const entry of resolvedStepTools) {
262
+ if (isServerToolSpec(entry)) {
263
+ serverToolSpecs.push(entry);
264
+ continue;
265
+ }
266
+ clientStepTools.push(entry);
267
+ }
268
+ return {
269
+ clientStepTools,
270
+ serverToolSpecs,
271
+ };
272
+ }
273
+ /**
274
+ * Append user input — through layer pipeline if layers exist, otherwise direct.
275
+ * The append pipeline may request a context re-render (e.g. a layer that
276
+ * expanded an input reference into new content); applied after recall.
277
+ */
278
+ async function appendUserInput(params) {
279
+ const { ctx, layers, input } = params;
280
+ if (typeof input !== 'string' || input.length === 0) {
281
+ return [];
282
+ }
283
+ if (layers !== undefined && layers.length > 0) {
284
+ return await runInputPipeline({
285
+ ctx,
286
+ layers,
287
+ input,
288
+ });
289
+ }
290
+ ctx.itemLog.append(createMessage(input, 'user'));
291
+ return [];
292
+ }
293
+ /**
294
+ * Splits the context budget across layers via `allocateBudgets`. An empty map
295
+ * when the step runs without layers.
296
+ */
297
+ function computeBudgetMap(params) {
298
+ const { layers, policy, systemPromptTokens } = params;
299
+ if (layers === undefined || layers.length === 0) {
300
+ return new Map();
301
+ }
302
+ const { allocations } = allocateBudgets({
303
+ layers,
304
+ totalBudget: policy.tokenBudget,
305
+ systemPromptTokens,
306
+ responseReserve: policy.responseReserve,
307
+ });
308
+ return new Map(allocations.map((a) => [
309
+ a.layerId,
310
+ a.allocated,
311
+ ]));
312
+ }
313
+ /**
314
+ * Recall once per LLM step: atomic layers run in the hot path; eventual layers
315
+ * are served from cache. Results drive both the assembled context window and
316
+ * the per-layer usage breakdown (ctx.lastLayerUsage). Recall fires before the
317
+ * steering retry loop because retries replay the same context. Re-render
318
+ * requests collected from the input-append pipeline are applied on top.
319
+ */
320
+ async function gatherRecallResults(params) {
321
+ const { ctx, layers, recallQuery, budgetMap, slotOf, rerenderRequests } = params;
322
+ if (layers === undefined || layers.length === 0) {
323
+ return emptyRecall;
324
+ }
325
+ const atomic = await ctx.harness.recallLayersAtomic(layers, recallQuery, ctx, budgetMap);
326
+ const eventual = await ctx.harness.recallLayersEventual(layers, recallQuery, ctx, budgetMap);
327
+ let recallResults = [
328
+ ...atomic,
329
+ ...eventual,
330
+ ].sort((a, b) => (slotOf.get(a.layerId) ?? 0) - (slotOf.get(b.layerId) ?? 0));
331
+ if (rerenderRequests.length > 0) {
332
+ const rerendered = await ctx.harness.executeRerender(rerenderRequests, layers, ctx, budgetMap, recallQuery);
333
+ recallResults = mergeRecallResults([
334
+ ...recallResults,
335
+ ], rerendered, slotOf);
336
+ }
337
+ return recallResults;
338
+ }
339
+ /**
340
+ * Builds the items the model sees for one attempt: system messages stay at the
341
+ * front, the banded anchor/live/delta output rides between them and history,
342
+ * and steering guidance from a prior retry lands at the very tail. The
343
+ * projector enforces the token budget (drops highest-slot layer output, then
344
+ * oldest history).
345
+ */
346
+ async function assembleTurnItems(params) {
347
+ const { ctx, layers, banded, steeringTail, viewPolicy } = params;
348
+ const rawHistoryItems = ctx.itemLog.items;
349
+ if (layers === undefined || layers.length === 0) {
350
+ return rawHistoryItems;
351
+ }
352
+ const projectedHistoryItems = await ctx.harness.projectHistory(layers, rawHistoryItems, ctx);
353
+ const systemItems = [];
354
+ const nonSystemHistory = [];
355
+ const tailIds = new Set(steeringTail.map((i) => i.id));
356
+ for (const item of projectedHistoryItems) {
357
+ if (item.type === 'message' && item.role === 'system') {
358
+ systemItems.push(item);
359
+ continue;
360
+ }
361
+ // Steering guidance is re-placed at the tail; skip it here so the retry
362
+ // does not see the same correction twice.
363
+ if ('id' in item && typeof item.id === 'string' && tailIds.has(item.id)) {
364
+ continue;
365
+ }
366
+ nonSystemHistory.push(item);
367
+ }
368
+ return assembleView({
369
+ systemPromptItems: systemItems,
370
+ layerOutputItems: banded.anchorItems,
371
+ historyItems: nonSystemHistory,
372
+ liveLayerItems: banded.liveItems,
373
+ deltaItems: banded.deltaItems,
374
+ tailItems: steeringTail,
375
+ policy: viewPolicy,
376
+ });
377
+ }
378
+ /**
379
+ * Builds the callModel request for one attempt. Cancellation reaches INSIDE
380
+ * the model call, not just the step boundary: the caller's abort signal stops
381
+ * the stream and the tool-round loop mid flight, so `ctx.abort()` /
382
+ * `harness.cancel()` don't wait out a long generation before taking effect
383
+ * (spec 09, Cancellation item 2).
384
+ */
385
+ function buildModelRequest(params) {
386
+ const { step, ctx, layers } = params;
387
+ const _serverTools = params.serverToolSpecs.length > 0 ? params.serverToolSpecs : undefined;
388
+ const signal = isContextImpl(ctx) ? ctx.abortSignal : undefined;
389
+ if (params.resolvedTools) {
390
+ return {
391
+ model: params.resolvedModel,
392
+ items: params.assembledItems,
393
+ instructions: params.resolvedInstructions,
394
+ tools: params.resolvedTools,
395
+ params: step.params,
396
+ outputSchema: params.outputSchema,
397
+ outputJsonSchema: params.step.outputJsonSchema,
398
+ emit: step.emit,
399
+ _serverTools,
400
+ ctx,
401
+ layers,
402
+ allowedToolNames: params.allowedToolNames,
403
+ nodeId: step.id,
404
+ parentSpan: ctx.span,
405
+ signal,
406
+ };
407
+ }
408
+ return {
409
+ model: params.resolvedModel,
410
+ items: params.assembledItems,
411
+ instructions: params.resolvedInstructions,
412
+ params: step.params,
413
+ outputSchema: params.outputSchema,
414
+ outputJsonSchema: params.step.outputJsonSchema,
415
+ emit: step.emit,
416
+ _serverTools,
417
+ nodeId: step.id,
418
+ parentSpan: ctx.span,
419
+ signal,
420
+ };
421
+ }
422
+ /**
423
+ * Records a finished model response on the context: appends response items to
424
+ * the log, publishes `lastStepMeta`, and commits the per-layer usage breakdown
425
+ * (what the model was actually shown, pinned replays included — raw recall
426
+ * output would report content it never saw).
427
+ */
428
+ function recordTurnOutcome(params) {
429
+ const { ctx, response, banded } = params;
430
+ const toolCalls = [];
431
+ for (const item of response.items) {
432
+ ctx.itemLog.append(item);
433
+ if (isFunctionCall(item)) {
434
+ toolCalls.push(item);
435
+ }
436
+ }
437
+ const meta = {
438
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
439
+ usage: response.usage,
440
+ cost: response.cost,
441
+ responseItems: response.items,
442
+ };
443
+ if (isMutableContext(ctx)) {
444
+ ctx.lastStepMeta = meta;
445
+ }
446
+ commitLayerUsage(ctx, computeLayerUsage({
447
+ ctx,
448
+ modelId: params.resolvedModel,
449
+ instructions: params.resolvedInstructions,
450
+ tools: params.resolvedTools,
451
+ recallResults: banded.servedPerLayer,
452
+ serveInfo: banded.serveInfo,
453
+ epoch: banded.epoch,
454
+ }));
455
+ }
456
+ export async function executeCallModel(step, input, ctx, layers) {
265
457
  const baseCtx = frameworkCast(ctx);
266
458
  const hasLayers = layers !== undefined && layers.length > 0;
267
459
  // Resolve lazy step fields up-front. Function-form getters run once per
@@ -271,49 +463,19 @@ export async function executeLLM(step, input, ctx, layers) {
271
463
  if (!resolvedModel || resolvedModel.trim() === '') {
272
464
  throw new NoeticConfigError({
273
465
  code: 'MISSING_MODEL',
274
- message: `step.llm(${JSON.stringify(step.id)}) resolved model to an empty string.`,
466
+ message: `callModel(${JSON.stringify(step.id)}) resolved model to an empty string.`,
275
467
  hint: "Ensure `model` (or your `(ctx) => string` getter) returns a non-empty identifier, e.g. 'anthropic/claude-sonnet-4-20250514'.",
276
468
  });
277
469
  }
278
470
  const baseInstructions = await resolveLazy(step.instructions, ctx);
279
471
  const { outputCodec, outputSchema, resolvedInstructions } = resolveOutputMode(step.output, baseInstructions);
280
472
  const resolvedStepTools = await resolveLazy(step.tools, ctx);
281
- // `step.tools` is heterogeneous: client `Tool`s plus inline OpenRouter
282
- // server-tool specs (web search/fetch). Partition them — client tools flow
283
- // through the unified-tool / allowedToolNames / steering machinery as before;
284
- // server-tool specs bypass it entirely and are stamped onto the model request
285
- // (`_serverTools`) for the model caller to wrap via the SDK's `serverTool()`.
286
- // `undefined` (unrestricted) is preserved as-is; only a present array is
287
- // partitioned so the `[] = opt-out` semantics of step.tools stay intact.
288
- let clientStepTools;
289
- const serverToolSpecs = [];
290
- if (resolvedStepTools !== undefined) {
291
- clientStepTools = [];
292
- for (const entry of resolvedStepTools) {
293
- if (isServerToolSpec(entry)) {
294
- serverToolSpecs.push(entry);
295
- }
296
- else {
297
- clientStepTools.push(entry);
298
- }
299
- }
300
- }
301
- // Append user input — through layer pipeline if layers exist, otherwise direct.
302
- // The append pipeline may request a context re-render (e.g. a layer that
303
- // expanded an input reference into new content); applied after recall below.
304
- let rerenderRequests = [];
305
- if (typeof input === 'string' && input.length > 0) {
306
- if (hasLayers) {
307
- rerenderRequests = await runInputPipeline({
308
- ctx: baseCtx,
309
- layers,
310
- input,
311
- });
312
- }
313
- else {
314
- baseCtx.itemLog.append(createMessage(input, 'user'));
315
- }
316
- }
473
+ const { clientStepTools, serverToolSpecs } = partitionStepTools(resolvedStepTools);
474
+ const rerenderRequests = await appendUserInput({
475
+ ctx: baseCtx,
476
+ layers,
477
+ input,
478
+ });
317
479
  const { tools: resolvedTools, allowedToolNames } = resolveToolsAndRestrictions(clientStepTools, layers, baseCtx);
318
480
  // Resolve the projection policy (step override > harness default > fallback)
319
481
  // and split the context budget across layers via allocateBudgets.
@@ -326,120 +488,92 @@ export async function executeLLM(step, input, ctx, layers) {
326
488
  ...policy,
327
489
  tokenBudget: Math.max(0, policy.tokenBudget - systemPromptTokens),
328
490
  };
329
- let budgetMap = new Map();
330
- if (hasLayers) {
331
- const { allocations } = allocateBudgets({
332
- layers,
333
- totalBudget: policy.tokenBudget,
334
- systemPromptTokens,
335
- responseReserve: policy.responseReserve,
336
- });
337
- budgetMap = new Map(allocations.map((a) => [
338
- a.layerId,
339
- a.allocated,
340
- ]));
341
- }
342
- // Recall once per LLM step: atomic layers run in the hot path; eventual layers
343
- // are served from cache. Results drive both the assembled context window and
344
- // the per-layer usage breakdown (ctx.lastLayerUsage). Recall fires before the
345
- // steering retry loop because retries replay the same context.
491
+ const budgetMap = computeBudgetMap({
492
+ layers,
493
+ policy,
494
+ systemPromptTokens,
495
+ });
346
496
  const recallQuery = typeof input === 'string' ? input : '';
347
497
  const slotOf = new Map((layers ?? []).map((l) => [
348
498
  l.id,
349
499
  l.slot,
350
500
  ]));
351
- let recallResults = emptyRecall;
352
- if (hasLayers) {
353
- const atomic = await baseCtx.harness.recallLayersAtomic(layers, recallQuery, baseCtx, budgetMap);
354
- const eventual = await baseCtx.harness.recallLayersEventual(layers, recallQuery, baseCtx, budgetMap);
355
- recallResults = [
356
- ...atomic,
357
- ...eventual,
358
- ].sort((a, b) => (slotOf.get(a.layerId) ?? 0) - (slotOf.get(b.layerId) ?? 0));
359
- // Apply re-render requests collected from the input-append pipeline.
360
- if (rerenderRequests.length > 0) {
361
- const rerendered = await baseCtx.harness.executeRerender(rerenderRequests, layers, baseCtx, budgetMap, recallQuery);
362
- recallResults = mergeRecallResults([
363
- ...recallResults,
364
- ], rerendered, slotOf);
365
- }
366
- }
367
- const layerOutputItems = recallResults.flatMap((r) => r.items);
501
+ const recallResults = await gatherRecallResults({
502
+ ctx: baseCtx,
503
+ layers,
504
+ recallQuery,
505
+ budgetMap,
506
+ slotOf,
507
+ rerenderRequests,
508
+ });
509
+ // Split recall output into the cache-stable anchor band and the live band,
510
+ // replaying pinned anchors and gathering any changes into one supersede
511
+ // message. Runs once, outside the retry loop, for the same reason recall does:
512
+ // a retry replays this view rather than building a new one.
513
+ // A harness that predates anchoring carries no store. Give it a throwaway:
514
+ // nothing survives the turn, so its layers render fresh every time — the
515
+ // behaviour it had before the bands existed.
516
+ const cacheStore = baseCtx.harness.contextCache ?? createContextCacheStore();
517
+ const banded = await prepareBandedView({
518
+ recallResults,
519
+ layers: layers ?? [],
520
+ execCtx: contextToExecCtx(baseCtx),
521
+ store: cacheStore,
522
+ config: baseCtx.harness.config.contextCache,
523
+ instructions: resolvedInstructions,
524
+ policy: viewPolicy,
525
+ systemPromptItems: [],
526
+ budgets: budgetMap,
527
+ });
528
+ stampAnchoringAttributes(baseCtx.span, banded);
368
529
  let retries = 0;
530
+ // Guidance from a steering retry rides at the very end of the view, after the
531
+ // live band and the supersedes, rather than wherever history happens to put it.
532
+ let steeringTail = [];
533
+ let cacheJudged = false;
369
534
  while (retries <= MAX_STEERING_RETRIES) {
370
- const rawHistoryItems = baseCtx.itemLog.items;
371
- const projectedHistoryItems = hasLayers
372
- ? await baseCtx.harness.projectHistory(layers, rawHistoryItems, baseCtx)
373
- : rawHistoryItems;
374
- let assembledItems;
375
- if (hasLayers) {
376
- // Keep system messages at the front; the projector enforces the token
377
- // budget (drops highest-slot layer output, then oldest history).
378
- const systemItems = [];
379
- const nonSystemHistory = [];
380
- for (const item of projectedHistoryItems) {
381
- if (item.type === 'message' && item.role === 'system') {
382
- systemItems.push(item);
383
- continue;
384
- }
385
- nonSystemHistory.push(item);
386
- }
387
- assembledItems = assembleView({
388
- systemPromptItems: systemItems,
389
- layerOutputItems,
390
- historyItems: nonSystemHistory,
391
- policy: viewPolicy,
392
- });
393
- }
394
- else {
395
- assembledItems =
396
- projectedHistoryItems === rawHistoryItems
397
- ? rawHistoryItems
398
- : [
399
- ...projectedHistoryItems,
400
- ];
401
- }
402
- const _serverTools = serverToolSpecs.length > 0 ? serverToolSpecs : undefined;
403
- // Cancellation reaches INSIDE the model call, not just the step boundary:
404
- // the caller's abort signal stops the stream and the tool-round loop mid
405
- // flight, so `ctx.abort()` / `harness.cancel()` don't wait out a long
406
- // generation before taking effect (spec 09, Cancellation item 2).
407
- const signal = isContextImpl(baseCtx) ? baseCtx.abortSignal : undefined;
408
- const request = resolvedTools
409
- ? {
410
- model: resolvedModel,
411
- items: assembledItems,
412
- instructions: resolvedInstructions,
413
- tools: resolvedTools,
414
- params: step.params,
415
- outputSchema: outputSchema,
416
- emit: step.emit,
417
- _serverTools,
418
- ctx: baseCtx,
419
- layers,
420
- allowedToolNames,
421
- nodeId: step.id,
422
- parentSpan: baseCtx.span,
423
- signal,
424
- }
425
- : {
426
- model: resolvedModel,
427
- items: assembledItems,
428
- instructions: resolvedInstructions,
429
- params: step.params,
430
- outputSchema: outputSchema,
431
- emit: step.emit,
432
- _serverTools,
433
- nodeId: step.id,
434
- parentSpan: baseCtx.span,
435
- signal,
436
- };
535
+ const assembledItems = await assembleTurnItems({
536
+ ctx: baseCtx,
537
+ layers,
538
+ banded,
539
+ steeringTail,
540
+ viewPolicy,
541
+ });
542
+ const request = buildModelRequest({
543
+ step,
544
+ ctx: baseCtx,
545
+ layers,
546
+ resolvedModel,
547
+ resolvedInstructions,
548
+ resolvedTools,
549
+ allowedToolNames,
550
+ outputSchema,
551
+ serverToolSpecs,
552
+ assembledItems,
553
+ });
437
554
  const response = await baseCtx.harness.callModel(request);
438
555
  // Track tokens/cost for EVERY model call, including responses steering
439
556
  // subsequently rejects (Deny throw / Guide retry below) — the spend is
440
557
  // real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
441
558
  // tokens accumulate across all LLM calls).
442
559
  trackUsage(baseCtx, response);
560
+ // Ask the model's own token report whether the prompt prefix survived. A
561
+ // clear miss records a re-anchor that the next assembly picks up — pins are
562
+ // never touched here, so a response steering later rejects cannot leave the
563
+ // epoch half-rebuilt.
564
+ // Judged once per assembly, not once per call: a steering retry re-sends
565
+ // the same view, so counting its response again would reach the give-up
566
+ // threshold inside a single turn and blind the lineage for good.
567
+ if (banded.epoch && !cacheJudged) {
568
+ cacheJudged = true;
569
+ noteCacheOutcome({
570
+ store: cacheStore,
571
+ key: lineageKey(contextToExecCtx(baseCtx)),
572
+ response,
573
+ config: resolveCacheConfig(baseCtx.harness.config.contextCache),
574
+ expectedTokens: systemPromptTokens + banded.epoch.anchorTokens,
575
+ });
576
+ }
443
577
  // An abort that landed mid-call returns whatever rounds completed before
444
578
  // the signal fired. The spend is charged above, but a truncated generation
445
579
  // is not this step's output — surface cancellation instead.
@@ -458,34 +592,28 @@ export async function executeLLM(step, input, ctx, layers) {
458
592
  });
459
593
  }
460
594
  if (decision.action === SteeringAction.Guide && retries < MAX_STEERING_RETRIES) {
461
- baseCtx.itemLog.append(createMessage(decision.guidance ?? 'Please adjust your response.', 'developer'));
595
+ const guidance = createMessage(decision.guidance ?? 'Please adjust your response.', 'developer');
596
+ // Logged so it persists into the conversation, and carried as the tail
597
+ // so the retry's view ends on the correction rather than burying it
598
+ // ahead of the live band. Assembly drops it from history to avoid
599
+ // showing it twice.
600
+ baseCtx.itemLog.append(guidance);
601
+ steeringTail = [
602
+ ...steeringTail,
603
+ guidance,
604
+ ];
462
605
  retries++;
463
606
  continue;
464
607
  }
465
608
  }
466
- const toolCalls = [];
467
- for (const item of response.items) {
468
- baseCtx.itemLog.append(item);
469
- if (isFunctionCall(item)) {
470
- toolCalls.push(item);
471
- }
472
- }
473
- const meta = {
474
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
475
- usage: response.usage,
476
- cost: response.cost,
477
- responseItems: response.items,
478
- };
479
- if (isMutableContext(baseCtx)) {
480
- baseCtx.lastStepMeta = meta;
481
- }
482
- commitLayerUsage(baseCtx, computeLayerUsage({
609
+ recordTurnOutcome({
483
610
  ctx: baseCtx,
484
- modelId: resolvedModel,
485
- instructions: resolvedInstructions,
486
- tools: resolvedTools,
487
- recallResults,
488
- }));
611
+ response,
612
+ resolvedModel,
613
+ resolvedInstructions,
614
+ resolvedTools,
615
+ banded,
616
+ });
489
617
  if (hasLayers) {
490
618
  await baseCtx.harness.storeLayers(layers, response, baseCtx);
491
619
  }
@@ -579,7 +707,7 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
579
707
  // Build unified tool set for child from its step tree + layers, plus the
580
708
  // parent's unified tools. Sub-agents that read tools dynamically (via
581
709
  // `ctx.unifiedTools`) otherwise lose the harness tool pool at the spawn
582
- // boundary — a child whose `step.llm` resolves `tools` from context would
710
+ // boundary — a child whose `callModel` resolves `tools` from context would
583
711
  // see nothing. Inheriting the parent's tools keeps the harness toolset
584
712
  // available across spawns; the child's own step/layer tools take precedence
585
713
  // on name collision (dedup keeps the first occurrence), and children that
@@ -674,7 +802,7 @@ function mergeLayers(existing, provided) {
674
802
  * Unlike spawn, provide does not create a new itemLog or clone state.
675
803
  * Events flow through to the parent in real-time.
676
804
  */
677
- export async function executeProvide(step, input, ctx, executeStep) {
805
+ export async function executeWithContext(step, input, ctx, executeStep) {
678
806
  const baseCtx = frameworkCast(ctx);
679
807
  const previousLayers = baseCtx.layers;
680
808
  const previousUnifiedTools = baseCtx.unifiedTools;
@@ -729,7 +857,7 @@ async function consumeToolGenerator(params) {
729
857
  event: next.value,
730
858
  },
731
859
  });
732
- // A direct `step.tool` has no model call id — the step id keys its region.
860
+ // A direct `invokeTool` has no model call id — the step id keys its region.
733
861
  emitToolUi({
734
862
  ctx: params.ctx,
735
863
  tool: params.tool,
@@ -740,20 +868,21 @@ async function consumeToolGenerator(params) {
740
868
  });
741
869
  }
742
870
  }
743
- export async function executeTool(step, input, ctx, harness, layers) {
871
+ export async function executeInvokeTool(step, input, ctx, harness, layers) {
744
872
  const baseCtx = frameworkCast(ctx);
745
873
  const args = step.args ? Object.assign({}, input, step.args) : input;
746
- const parseResult = step.tool.input.safeParse(args);
874
+ const parseResult = await validateSchema(step.tool.input, args);
747
875
  if (!parseResult.success) {
748
876
  throw new NoeticErrorImpl({
749
877
  kind: 'step_failed',
750
878
  stepId: step.id,
751
- cause: new Error(`Tool input validation failed: ${parseResult.error.message}`),
879
+ cause: new Error(`Tool input validation failed: ${parseResult.zodError.message}`),
752
880
  retriesExhausted: false,
753
881
  });
754
882
  }
883
+ const parsedArgs = parseResult.value;
755
884
  if (layers && layers.length > 0) {
756
- const decision = await harness.beforeToolCall(layers, step.tool.name, parseResult.data, baseCtx);
885
+ const decision = await harness.beforeToolCall(layers, step.tool.name, parsedArgs, baseCtx);
757
886
  if (decision.action !== SteeringAction.Allow) {
758
887
  throw new NoeticErrorImpl({
759
888
  kind: 'steering_denied',
@@ -766,18 +895,18 @@ export async function executeTool(step, input, ctx, harness, layers) {
766
895
  tool: step.tool,
767
896
  callId: step.id,
768
897
  phase: 'call',
769
- args: parseResult.data,
898
+ args: parsedArgs,
770
899
  });
771
900
  try {
772
901
  const toolCtx = buildToolExecutionContext(baseCtx, harness);
773
- const execResult = step.tool.execute(parseResult.data, toolCtx);
902
+ const execResult = step.tool.execute(parsedArgs, toolCtx);
774
903
  const result = isAsyncGenerator(execResult)
775
904
  ? await consumeToolGenerator({
776
905
  generator: execResult,
777
906
  stepId: step.id,
778
907
  tool: step.tool,
779
908
  ctx: baseCtx,
780
- args: parseResult.data,
909
+ args: parsedArgs,
781
910
  })
782
911
  : await execResult;
783
912
  emitToolUi({
@@ -785,7 +914,7 @@ export async function executeTool(step, input, ctx, harness, layers) {
785
914
  tool: step.tool,
786
915
  callId: step.id,
787
916
  phase: 'result',
788
- args: parseResult.data,
917
+ args: parsedArgs,
789
918
  output: result,
790
919
  });
791
920
  return frameworkCast(result);
@@ -796,7 +925,7 @@ export async function executeTool(step, input, ctx, harness, layers) {
796
925
  tool: step.tool,
797
926
  callId: step.id,
798
927
  phase: 'error',
799
- args: parseResult.data,
928
+ args: parsedArgs,
800
929
  error: e,
801
930
  });
802
931
  if (e instanceof NoeticErrorImpl) {