@noetic-tools/core 0.3.0 → 1.1.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.
@@ -152,8 +152,8 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
152
152
  cwdInit?: string;
153
153
  _broadcaster?: EventBroadcaster;
154
154
  }): Context;
155
- send<T>(channel: Channel<T>, value: T, _ctx: Context): void;
156
- recv<T>(channel: Channel<T>, _ctx: Context, opts?: {
155
+ send<T>(channel: Channel<T>, value: T, ctx: Context): Promise<void>;
156
+ recv<T>(channel: Channel<T>, ctx: Context, opts?: {
157
157
  timeout?: number;
158
158
  }): Promise<T>;
159
159
  tryRecv<T>(channel: Channel<T>, _ctx: Context): T | null;
@@ -137,7 +137,11 @@ export class AgentHarness {
137
137
  this.client = opts._testCallModel ? undefined : createClient(opts.llm);
138
138
  this.channelStore = new ChannelStore();
139
139
  this.traceExporter = opts.traceExporter ?? new NoopExporter();
140
- this.layerStateStore = opts.layerStateStore ?? createLayerStateStore();
140
+ this.layerStateStore =
141
+ opts.layerStateStore ??
142
+ createLayerStateStore((layerId, hook, error) => {
143
+ console.warn(`[noetic] memory layer '${layerId}' ${hook} error:`, error);
144
+ });
141
145
  this.recallCache = createRecallCache();
142
146
  this.defaultDeliveryMode = opts.defaultDeliveryMode ?? 'next-turn';
143
147
  this.streamIdleTimeoutMs = opts.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
@@ -366,11 +370,19 @@ export class AgentHarness {
366
370
  cwdState: resolveContextCwdState(this.rootCwdState, opts?.parent, cwdInit),
367
371
  });
368
372
  }
369
- send(channel, value, _ctx) {
370
- this.channelStore.send(channel, value);
373
+ send(channel, value, ctx) {
374
+ // Internal sender: back-pressured on full queue channels. The calling
375
+ // context's abort signal rejects a parked send with 'cancelled'.
376
+ const signal = ctx instanceof ContextImpl ? ctx.abortSignal : undefined;
377
+ return this.channelStore.send(channel, value, {
378
+ signal,
379
+ });
371
380
  }
372
- recv(channel, _ctx, opts) {
373
- return this.channelStore.recv(channel, opts?.timeout);
381
+ recv(channel, ctx, opts) {
382
+ // Wire the calling context's abort signal so ctx.abort() rejects a
383
+ // pending recv with { kind: 'cancelled' } instead of hanging.
384
+ const signal = ctx instanceof ContextImpl ? ctx.abortSignal : undefined;
385
+ return this.channelStore.recv(channel, opts?.timeout, signal);
374
386
  }
375
387
  tryRecv(channel, _ctx) {
376
388
  return this.channelStore.tryRecv(channel);
@@ -400,7 +400,6 @@ export class AgentHarnessModelCaller {
400
400
  await this.executeToolRound({
401
401
  functionCalls,
402
402
  request,
403
- roundItemSchemas,
404
403
  allItems,
405
404
  conversationInput,
406
405
  round,
@@ -517,7 +516,7 @@ export class AgentHarnessModelCaller {
517
516
  };
518
517
  }
519
518
  async executeToolRound(params) {
520
- const { functionCalls, request, roundItemSchemas, allItems, conversationInput } = params;
519
+ const { functionCalls, request, allItems, conversationInput } = params;
521
520
  params.emitIfAllowed('tool_round_started', {
522
521
  round: params.round,
523
522
  toolCount: functionCalls.length,
@@ -538,7 +537,6 @@ export class AgentHarnessModelCaller {
538
537
  await this.executeFunctionCall({
539
538
  fc,
540
539
  request,
541
- roundItemSchemas,
542
540
  allItems,
543
541
  conversationInput,
544
542
  emitIfAllowed: params.emitIfAllowed,
@@ -550,7 +548,7 @@ export class AgentHarnessModelCaller {
550
548
  });
551
549
  }
552
550
  async executeFunctionCall(params) {
553
- const { fc, request, roundItemSchemas, allItems, conversationInput } = params;
551
+ const { fc, request, allItems, conversationInput } = params;
554
552
  params.emitIfAllowed('tool_call_started', {
555
553
  name: fc.name,
556
554
  callId: fc.callId,
@@ -563,7 +561,6 @@ export class AgentHarnessModelCaller {
563
561
  this.recordMalformedToolCall({
564
562
  fc,
565
563
  request,
566
- roundItemSchemas,
567
564
  allItems,
568
565
  conversationInput,
569
566
  emitIfAllowed: params.emitIfAllowed,
@@ -582,10 +579,13 @@ export class AgentHarnessModelCaller {
582
579
  harness: this.opts.harness,
583
580
  layers: request.layers,
584
581
  });
582
+ // Owner-scoped result validation: a tool's `toolResults` schemas apply
583
+ // only to that tool's own result items. Harness-level `opts.itemSchemas`
584
+ // stay global by design; sibling tools' schemas never reject this item.
585
585
  const outputItem = createToolResultItem({
586
586
  output: toolResult.output,
587
587
  callId: fc.callId,
588
- roundItemSchemas,
588
+ roundItemSchemas: this.opts.itemSchemas.extend(toolForCall?.itemSchemas),
589
589
  tool: toolForCall,
590
590
  callItem: fc,
591
591
  args: parsedArgs,
@@ -605,13 +605,14 @@ export class AgentHarnessModelCaller {
605
605
  });
606
606
  }
607
607
  recordMalformedToolCall(params) {
608
- const { fc, request, roundItemSchemas, allItems, conversationInput } = params;
608
+ const { fc, request, allItems, conversationInput } = params;
609
609
  const errorOutput = `Error: malformed JSON in tool arguments: ${fc.arguments}`;
610
610
  const toolForCall = request.tools?.find((tool) => tool.name === fc.name);
611
+ // Owner-scoped result validation (see executeFunctionCall).
611
612
  const outputItem = createToolResultItem({
612
613
  output: errorOutput,
613
614
  callId: fc.callId,
614
- roundItemSchemas,
615
+ roundItemSchemas: this.opts.itemSchemas.extend(toolForCall?.itemSchemas),
615
616
  tool: toolForCall,
616
617
  callItem: fc,
617
618
  error: true,
@@ -5,6 +5,14 @@ export declare function buildItemSchemaRegistry({ base, layers, tools, }: {
5
5
  layers?: ReadonlyArray<MemoryLayer>;
6
6
  tools?: ReadonlyArray<Tool>;
7
7
  }): ItemSchemaRegistry;
8
+ /**
9
+ * Build and validate a `function_call_output` item for a tool result.
10
+ *
11
+ * `roundItemSchemas` must be the OWNER-SCOPED registry for the called tool
12
+ * (harness base extended with only that tool's `itemSchemas`), so one tool's
13
+ * `toolResults` schemas never reject a sibling tool's result items. Tools
14
+ * without `itemSchemas` fall back to the base structural parse.
15
+ */
8
16
  export declare function createToolResultItem({ output, callId, roundItemSchemas, tool, callItem, args, result, error, }: {
9
17
  output: string;
10
18
  callId: string;
@@ -24,6 +24,14 @@ export function buildItemSchemaRegistry({ base, layers, tools, }) {
24
24
  .extend(collectLayerItemSchemaExtensions(layers))
25
25
  .extend(collectToolItemSchemaExtensions(tools));
26
26
  }
27
+ /**
28
+ * Build and validate a `function_call_output` item for a tool result.
29
+ *
30
+ * `roundItemSchemas` must be the OWNER-SCOPED registry for the called tool
31
+ * (harness base extended with only that tool's `itemSchemas`), so one tool's
32
+ * `toolResults` schemas never reject a sibling tool's result items. Tools
33
+ * without `itemSchemas` fall back to the base structural parse.
34
+ */
27
35
  export function createToolResultItem({ output, callId, roundItemSchemas, tool, callItem, args, result, error, }) {
28
36
  const baseItem = {
29
37
  id: crypto.randomUUID(),
package/dist/index.d.ts CHANGED
@@ -55,7 +55,7 @@ export { execute } from './interpreter/execute.js';
55
55
  /** @public */
56
56
  /** @public */
57
57
  /** @public */
58
- export type { DurableTaskState, DurableTaskStateConfig, FactExtractor, FactSearcher, HistoryWindowConfig, ObservationalMemoryConfig, ObservationalState, PlanEnterSessionCallback, PlanExecutionEntry, PlanExitCallback, PlanMemoryConfig, PlanState, TemporalFact, TemporalMemoryConfig, TemporalSearchResult, WorkingMemoryConfig, WorkingMemoryState, } from '@noetic-tools/memory';
58
+ export type { DurableTaskState, FactExtractor, FactSearcher, HistoryWindowConfig, ObservationalMemoryConfig, ObservationalState, PlanEnterSessionCallback, PlanExecutionEntry, PlanExitCallback, PlanMemoryConfig, PlanState, TemporalFact, TemporalMemoryConfig, TemporalSearchResult, WorkingMemoryConfig, WorkingMemoryState, } from '@noetic-tools/memory';
59
59
  /** @public */
60
60
  /** @public */
61
61
  /** @public */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Action step handlers: run, llm, spawn, provide, tool.
3
3
  */
4
- import { createMessage, estimateTokens, extractAssistantText, frameworkCast, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
4
+ import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
5
  import { ZodError } from 'zod';
6
6
  import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, snapshotCwdState, spawnLayers, } from './action-deps.js';
7
7
  import { SteeringAction } from './action-types.js';
@@ -15,10 +15,23 @@ export async function executeRun(step, input, ctx) {
15
15
  const maxAttempts = retry?.maxAttempts ?? 1;
16
16
  let lastError = new Error('No attempts executed');
17
17
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
18
+ // Cancellation is not a retriable error (spec 09) — an abort that
19
+ // arrived between attempts must stop the loop before re-executing.
20
+ if (ctx.aborted) {
21
+ throw new NoeticErrorImpl({
22
+ kind: 'cancelled',
23
+ reason: ctx.abortReason ?? 'context aborted',
24
+ });
25
+ }
18
26
  try {
19
27
  return await step.execute(input, ctx);
20
28
  }
21
29
  catch (e) {
30
+ // Rethrow cancellation immediately — retrying it would re-run side
31
+ // effects after abort and bury the 'cancelled' kind under step_failed.
32
+ if (isNoeticError(e) && e.noeticError.kind === 'cancelled') {
33
+ throw e;
34
+ }
22
35
  lastError = e instanceof Error ? e : new Error(String(e));
23
36
  if (attempt < maxAttempts - 1 && retry) {
24
37
  const delay = computeDelay(retry, attempt);
@@ -290,6 +303,11 @@ export async function executeLLM(step, input, ctx, layers) {
290
303
  emit: step.emit,
291
304
  };
292
305
  const response = await baseCtx.harness.callModel(request);
306
+ // Track tokens/cost for EVERY model call, including responses steering
307
+ // subsequently rejects (Deny throw / Guide retry below) — the spend is
308
+ // real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
309
+ // tokens accumulate across all LLM calls).
310
+ trackUsage(baseCtx, response);
293
311
  if (hasLayers) {
294
312
  const decision = await baseCtx.harness.afterModelCall(layers, response, baseCtx);
295
313
  if (decision.action === SteeringAction.Deny) {
@@ -320,7 +338,6 @@ export async function executeLLM(step, input, ctx, layers) {
320
338
  if (isMutableContext(baseCtx)) {
321
339
  baseCtx.lastStepMeta = meta;
322
340
  }
323
- trackUsage(baseCtx, response);
324
341
  commitLayerUsage(baseCtx, computeLayerUsage({
325
342
  ctx: baseCtx,
326
343
  modelId: resolvedModel,
@@ -135,9 +135,46 @@ function classifyResults(settled, paths) {
135
135
  };
136
136
  }
137
137
  async function executeAll(step, paths, _input, ctx, childContexts, executeStep, concurrency) {
138
- const tasks = paths.map((path, i) => () => executeStep(path, _input, frameworkCast(childContexts[i])));
138
+ // Fail-fast (specs 03/09): the first genuine path failure aborts every
139
+ // sibling child context. Cancellation is cooperative — in-flight siblings
140
+ // are still awaited (they stop at their next step boundary / blocked
141
+ // channel op) so fork_partial's succeeded/failed stay accurate; paths
142
+ // still queued behind the concurrency limit are skipped outright. Skipped
143
+ // and aborted paths appear in `failed` with kind 'cancelled'.
144
+ let firstFailureSeen = false;
145
+ const tasks = paths.map((path, i) => async () => {
146
+ if (firstFailureSeen) {
147
+ throw new NoeticErrorImpl({
148
+ kind: 'cancelled',
149
+ reason: `fork '${step.id}' sibling failed`,
150
+ });
151
+ }
152
+ try {
153
+ return await executeStep(path, _input, frameworkCast(childContexts[i]));
154
+ }
155
+ catch (e) {
156
+ const isCancellation = isNoeticError(e) && e.noeticError.kind === 'cancelled';
157
+ if (!isCancellation && !firstFailureSeen) {
158
+ firstFailureSeen = true;
159
+ for (let j = 0; j < childContexts.length; j++) {
160
+ if (j !== i) {
161
+ childContexts[j].abort(`fork '${step.id}' sibling failed`);
162
+ }
163
+ }
164
+ }
165
+ throw e;
166
+ }
167
+ });
139
168
  const settled = await runWithConcurrency(tasks, concurrency);
140
169
  const { succeeded, failed } = classifyResults(settled, paths);
170
+ // If the PARENT context was aborted mid-fork, the whole fork is cancelled —
171
+ // surface 'cancelled', not a partial-failure shape (spec 09, item 3).
172
+ if (ctx.aborted) {
173
+ throw new NoeticErrorImpl({
174
+ kind: 'cancelled',
175
+ reason: ctx.abortReason ?? 'context aborted',
176
+ });
177
+ }
141
178
  if (failed.length > 0) {
142
179
  throw new NoeticErrorImpl({
143
180
  kind: 'fork_partial',
@@ -208,9 +245,17 @@ async function executeRace(step, paths, input, ctx, childContexts, executeStep,
208
245
  }
209
246
  });
210
247
  }
211
- async function executeSettle(step, paths, input, _ctx, childContexts, executeStep, concurrency) {
248
+ async function executeSettle(step, paths, input, ctx, childContexts, executeStep, concurrency) {
212
249
  const tasks = paths.map((path, i) => () => executeStep(path, input, frameworkCast(childContexts[i])));
213
250
  const settled = await runWithConcurrency(tasks, concurrency);
251
+ // Parent aborted mid-fork: the fork is cancelled, merge never runs
252
+ // (spec 09, Cancellation item 3 — same rule as mode 'all').
253
+ if (ctx.aborted) {
254
+ throw new NoeticErrorImpl({
255
+ kind: 'cancelled',
256
+ reason: ctx.abortReason ?? 'context aborted',
257
+ });
258
+ }
214
259
  const results = settled.map((result, i) => {
215
260
  if (result.status === 'fulfilled') {
216
261
  return {
@@ -225,7 +270,7 @@ async function executeSettle(step, paths, input, _ctx, childContexts, executeSte
225
270
  error: toNoeticError(result.reason, paths[i].id),
226
271
  };
227
272
  });
228
- return step.merge(results, _ctx);
273
+ return step.merge(results, ctx);
229
274
  }
230
275
  function hasTextField(value) {
231
276
  return typeof value === 'object' && value !== null && 'text' in value;
@@ -242,7 +287,11 @@ async function recvInboxWithTimeout(ctx, step) {
242
287
  timeout: step.parkTimeout,
243
288
  });
244
289
  }
245
- catch {
290
+ catch (e) {
291
+ // Cancellation must terminate the loop, not read as "no message".
292
+ if (isNoeticError(e) && e.noeticError.kind === 'cancelled') {
293
+ throw e;
294
+ }
246
295
  // Expected: channel_timeout error when parkTimeout expires with no message.
247
296
  return null;
248
297
  }
@@ -307,6 +356,12 @@ export async function executeLoop(step, input, ctx, executeStep) {
307
356
  if (!step.onError || !isNoeticError(e)) {
308
357
  throw e;
309
358
  }
359
+ // Cancellation is not a retriable error (spec 09): it must terminate
360
+ // the loop promptly rather than be consumed by 'retry'/'skip'. Same
361
+ // guard as executeEvery's 'continue' policy below.
362
+ if (e.noeticError.kind === 'cancelled') {
363
+ throw e;
364
+ }
310
365
  const action = step.onError(e.noeticError, ctx);
311
366
  if (action === 'retry') {
312
367
  continue;
@@ -1,4 +1,14 @@
1
1
  import type { Channel, ChannelHandle, ExternalChannel } from '@noetic-tools/types';
2
+ /** Options accepted by internal (back-pressured) `send`. */
3
+ export interface ChannelSendOpts {
4
+ /**
5
+ * How long a sender may stay parked on a full queue before rejecting with
6
+ * `channel_timeout`. Default 30s; `0` disables the timeout.
7
+ */
8
+ timeout?: number;
9
+ /** Abort signal — rejects a parked sender with `cancelled`. */
10
+ signal?: AbortSignal;
11
+ }
2
12
  export declare class ChannelStore {
3
13
  private channels;
4
14
  private closedExecutions;
@@ -9,10 +19,40 @@ export declare class ChannelStore {
9
19
  * an `unsubscribe` so the caller can detach if it cancels first.
10
20
  */
11
21
  subscribeWake<T>(channel: Channel<T>, callback: () => void): () => void;
12
- send<T>(channel: Channel<T>, value: T): void;
13
- recv<T>(channel: Channel<T>, timeout?: number): Promise<T>;
22
+ /**
23
+ * Internal-sender write with back-pressure (spec 06). Delivery is
24
+ * synchronous when possible; the returned promise only parks when a queue
25
+ * channel is at capacity:
26
+ *
27
+ * - **value**: store last-write-wins value, drain ALL parked recv waiters,
28
+ * resolve immediately.
29
+ * - **queue**: hand off to a parked recv waiter or push below capacity —
30
+ * resolve immediately. At capacity, park the sender FIFO until a consumer
31
+ * dequeues an item; reject `channel_timeout` after `opts.timeout`
32
+ * (default 30s) or `cancelled` when `opts.signal` aborts.
33
+ * - **topic**: deliver to current subscribers, resolve immediately.
34
+ *
35
+ * External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
36
+ */
37
+ send<T>(channel: Channel<T>, value: T, opts?: ChannelSendOpts): Promise<void>;
38
+ /** Park an internal sender on a full queue until a slot frees (FIFO). */
39
+ private parkSender;
40
+ /**
41
+ * A dequeue freed a queue slot — shift the oldest parked sender's value
42
+ * into it (FIFO) and resolve that sender's promise.
43
+ */
44
+ private promotePendingSender;
45
+ /** Fire-and-reset the one-shot wake subscribers for a channel state. */
46
+ private fireWakes;
47
+ recv<T>(channel: Channel<T>, timeout?: number, signal?: AbortSignal): Promise<T>;
14
48
  tryRecv<T>(channel: Channel<T>): T | null;
15
49
  private waitWithTimeout;
50
+ /**
51
+ * External-sender write: synchronous, never back-pressured (spec 06,
52
+ * External Sender Back-Pressure). At capacity the OLDEST queued item is
53
+ * dropped (with a warning) so external callers never block.
54
+ */
55
+ private sendExternal;
16
56
  getHandle<T>(channel: ExternalChannel<T>, executionId: string): ChannelHandle<T>;
17
57
  closeExecution(executionId: string): void;
18
58
  }