@vincemakes/kiso-core 0.26.1 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/errors.d.ts CHANGED
@@ -6,4 +6,10 @@
6
6
  * providers proxy raw.
7
7
  */
8
8
  import type { StructuredError } from "./protocol/events.js";
9
- export declare function mapApiError(status: number | undefined, message: string): StructuredError;
9
+ /** CX-1 F8: `Retry-After` → milliseconds. Integer seconds or an HTTP-date;
10
+ * anything else — negative, non-finite, a date already past — is
11
+ * undefined (the kernel falls back to its own backoff). Never shortened:
12
+ * a wait above the kernel's cap is reported as-is and the kernel stops
13
+ * rather than retrying early. */
14
+ export declare function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined;
15
+ export declare function mapApiError(status: number | undefined, message: string, retryAfterMs?: number): StructuredError;
package/dist/errors.js CHANGED
@@ -5,8 +5,33 @@
5
5
  * (ADR-0005). 529 is the Cloudflare overload code most OpenAI-compat
6
6
  * providers proxy raw.
7
7
  */
8
- export function mapApiError(status, message) {
9
- const withStatus = (e) => status !== undefined ? { ...e, status } : e;
8
+ /** CX-1 F8: `Retry-After` → milliseconds. Integer seconds or an HTTP-date;
9
+ * anything else — negative, non-finite, a date already past — is
10
+ * undefined (the kernel falls back to its own backoff). Never shortened:
11
+ * a wait above the kernel's cap is reported as-is and the kernel stops
12
+ * rather than retrying early. */
13
+ export function parseRetryAfter(value, now = Date.now()) {
14
+ if (value === null || value === undefined)
15
+ return undefined;
16
+ const v = value.trim();
17
+ if (v === "")
18
+ return undefined;
19
+ if (/^\d+$/.test(v)) {
20
+ const ms = Number(v) * 1000;
21
+ return Number.isFinite(ms) ? ms : undefined;
22
+ }
23
+ const at = Date.parse(v);
24
+ if (!Number.isFinite(at))
25
+ return undefined;
26
+ const ms = at - now;
27
+ return ms >= 0 ? ms : undefined;
28
+ }
29
+ export function mapApiError(status, message, retryAfterMs) {
30
+ const withStatus = (e) => ({
31
+ ...e,
32
+ ...(status !== undefined ? { status } : {}),
33
+ ...(retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? { retryAfterMs } : {}),
34
+ });
10
35
  switch (status) {
11
36
  case 401:
12
37
  case 403:
@@ -168,6 +168,9 @@ export interface LoopConfig {
168
168
  */
169
169
  export declare const DEFAULT_MAX_TURNS: number;
170
170
  export declare const DEFAULT_MAX_RETRIES = 2;
171
+ /** CX-1 F8: the longest Retry-After the kernel will honor; beyond it the
172
+ * run stops with an explicit error rather than waiting or retrying early. */
173
+ export declare const RETRY_AFTER_MAX_MS = 60000;
171
174
  export declare function loop(config: LoopConfig): AsyncGenerator<Event>;
172
175
  /**
173
176
  * Adapter exceptions → StructuredError. Anything already shaped like one
@@ -63,6 +63,9 @@ import { messagesToEvents, MICROCOMPACTABLE, DO_NOT_COMPACT, projectMessages } f
63
63
  */
64
64
  export const DEFAULT_MAX_TURNS = Number.POSITIVE_INFINITY;
65
65
  export const DEFAULT_MAX_RETRIES = 2;
66
+ /** CX-1 F8: the longest Retry-After the kernel will honor; beyond it the
67
+ * run stops with an explicit error rather than waiting or retrying early. */
68
+ export const RETRY_AFTER_MAX_MS = 60_000;
66
69
  export async function* loop(config) {
67
70
  const log = config.log ?? new EventLog();
68
71
  const hooks = config.hooks ?? NoOpHooks;
@@ -73,6 +76,13 @@ export async function* loop(config) {
73
76
  const registry = mode?.visibleToolNames !== undefined
74
77
  ? config.registry.subset(mode.visibleToolNames)
75
78
  : config.registry;
79
+ // CX-1 F7b (audit F7): ONE tool table per request — captured at
80
+ // assembly, dispatched from until the next assembly. The definition
81
+ // the model saw, the schema the arguments are validated against, the
82
+ // execute that runs and the effects the scheduler reads are the same
83
+ // captured values; a live source that changes mid-request reaches the
84
+ // NEXT request.
85
+ let table = registry.snapshot();
76
86
  // Seed: a fresh log encodes the seed history as events so the projection
77
87
  // (and any later replay) contains it. A non-empty log (session resume)
78
88
  // is never re-seeded — the log already holds everything.
@@ -315,7 +325,7 @@ export async function* loop(config) {
315
325
  };
316
326
  const launch = (call) => {
317
327
  execActive += 1;
318
- const tool = registry.get(call.name);
328
+ const tool = table.get(call.name);
319
329
  const slot = reserve(tool);
320
330
  // EC-1 ③ — this call's place in the ASK order, filled in by the
321
331
  // decide chain (call order). `ahead` is the gate of an ask ACCEPTED
@@ -326,7 +336,7 @@ export async function* loop(config) {
326
336
  launches.push((async () => {
327
337
  try {
328
338
  decideChain = decideChain.then(async () => {
329
- const v = await decideCall(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicy, nextDecisionId, pushExec);
339
+ const v = await decideCall(call, table, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicy, nextDecisionId, pushExec);
330
340
  askOrder.ahead = askGate;
331
341
  if (v.action === "ask") {
332
342
  askGate = new Promise((res) => {
@@ -425,7 +435,7 @@ export async function* loop(config) {
425
435
  return;
426
436
  await acquireWindow();
427
437
  try {
428
- await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
438
+ await runLedgered(call, table, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
429
439
  }
430
440
  finally {
431
441
  releaseWindow();
@@ -521,7 +531,7 @@ export async function* loop(config) {
521
531
  // commit-required execution in the draft (impossible under
522
532
  // invariant 3) suppresses the marker — no void over a started
523
533
  // fact, the pre-F4 abandon exactly.
524
- const unsafeStartedInDraft = () => log.all.some((e) => e.type === "tool_execution_started" && e.seq > turnStart && registry.get(e.name)?.effects?.precommitSafe !== true);
534
+ const unsafeStartedInDraft = () => log.all.some((e) => e.type === "tool_execution_started" && e.seq > turnStart && table.get(e.name)?.effects?.precommitSafe !== true);
525
535
  const abandonDraft = async function* (reason) {
526
536
  settleTurn();
527
537
  violated = true;
@@ -549,7 +559,7 @@ export async function* loop(config) {
549
559
  messages,
550
560
  ...(config.reasoning !== undefined ? { reasoning: config.reasoning } : {}),
551
561
  ...(config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}),
552
- tools: registry.toSpecs(),
562
+ tools: (table = registry.snapshot()).specs,
553
563
  ...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
554
564
  ...(config.temperature !== undefined ? { temperature: config.temperature } : {}),
555
565
  ...(signal !== undefined ? { signal } : {}),
@@ -630,13 +640,26 @@ export async function* loop(config) {
630
640
  yield await terminal({ kind: "aborted", by: "user" });
631
641
  return;
632
642
  }
633
- const structured = toStructuredError(err);
643
+ let structured = toStructuredError(err);
644
+ // CX-1 F8 (ruled: the kernel owns retries): the n-th retry waits
645
+ // max(n × 250 ms, the provider's Retry-After), abortable — computed
646
+ // BEFORE `attempts` advances (the 2026-09-07 review's P3: computed
647
+ // after it, the first wait was 500 ms). A wait above the cap is
648
+ // never shortened — the run stops with an explicit error instead.
649
+ const retryWait = () => Math.max((attempts + 1) * 250, structured.retryAfterMs ?? 0);
650
+ if (structured.retryable && attempts < maxRetries && retryWait() > RETRY_AFTER_MAX_MS) {
651
+ structured = {
652
+ ...structured,
653
+ retryable: false,
654
+ message: `${structured.message} (the provider asked to wait ${structured.retryAfterMs}ms — beyond the ${RETRY_AFTER_MAX_MS}ms cap; not retried)`,
655
+ };
656
+ }
634
657
  // Phase B: never SILENTLY re-stream a turn that already emitted
635
658
  // content — duplicates are worse than failures. Nothing streamed
636
659
  // yet: the cheap in-place retry (no draft exists to void).
637
660
  if (structured.retryable && !streamed && attempts < maxRetries) {
661
+ await sleep(retryWait(), signal); // abortable backoff, Retry-After honored
638
662
  attempts += 1;
639
- await sleep(attempts * 250, signal); // abortable backoff
640
663
  continue;
641
664
  }
642
665
  // F4 — ABANDON HYGIENE, every streamed exit: settle, drain,
@@ -650,8 +673,8 @@ export async function* loop(config) {
650
673
  // F4 — the mid-stream retry: same classification, same per-turn
651
674
  // budget (ADR-0005 Amendment 1: frame state, per-process).
652
675
  if (structured.retryable && attempts < maxRetries && !unsafeStartedInDraft()) {
676
+ await sleep(retryWait(), signal); // the same abortable backoff, Retry-After honored
653
677
  attempts += 1;
654
- await sleep(attempts * 250, signal); // the same abortable backoff
655
678
  // Fresh per-attempt state — the marker is the boundary now.
656
679
  pending.length = 0;
657
680
  lastStop = undefined;
@@ -959,14 +982,18 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
959
982
  // its verdict. A throwing chain counts as ask. Allow/deny are recorded
960
983
  // durably with decidedBy = the deciding extension, never pausing for a
961
984
  // human.
962
- // CX-1 F1 (audit F1): a durable decision is consumable ONCE — by the
963
- // invocation it was recorded for, or by that invocation's RE-ISSUE on
964
- // resume (same callId, same tool, identical input, and the original
965
- // never produced a result). A provider callId alone is correlation,
966
- // never identity: the old lookup found the current call itself
967
- // (already appended), compared its input to its own, and took the
968
- // FIRST decision with that callId in the whole log — so a reused
969
- // callId inherited an old allow for a new call.
985
+ // CX-1 F1 (audit F1): a durable decision binds to EXACTLY the
986
+ // invocation it was recorded for — `invocationSeq === call.seq`. A
987
+ // provider callId alone is correlation, never identity: the old lookup
988
+ // took the FIRST decision with that callId in the whole log, so a
989
+ // reused callId inherited an old allow for a new call. The 0.26.1
990
+ // "re-issue" exception (same callId, same input, no result yet) is
991
+ // RETRACTED (the 2026-09-07 review's P1): it let a same-turn duplicate
992
+ // and a voided draft's retry inherit a verdict, and nothing needed it —
993
+ // a committed turn's pending call is executed by the recovery driver
994
+ // under its ORIGINAL seq, and a re-streamed call after a void is a new
995
+ // invocation the chain decides again (a policy is a function; running
996
+ // it twice costs nothing and gives a stateful one its say).
970
997
  //
971
998
  // The decision's invocationSeq names the original call (every
972
999
  // producer stamps it, round 5). A legacy decision (no invocationSeq,
@@ -987,28 +1014,6 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
987
1014
  }
988
1015
  return undefined;
989
1016
  };
990
- /** The re-issue rule: a resume re-issues the pending call under the SAME
991
- * provider id — same callId, same tool, identical input — and the
992
- * original never completed: no result and no other call under that id
993
- * stands between the original and this call. A different callId with
994
- * the same arguments is a new invocation (the /mode-switch shape: a
995
- * write denied under plan is asked afresh under default). */
996
- const reissues = (origSeq) => {
997
- const orig = all.find((e) => e.seq === origSeq);
998
- if (orig === undefined || orig.type !== "tool_call_end")
999
- return false;
1000
- if (orig.callId !== call.callId || orig.name !== call.name)
1001
- return false;
1002
- if (JSON.stringify(orig.input) !== JSON.stringify(call.input))
1003
- return false;
1004
- for (const e of all) {
1005
- if (e.seq <= origSeq || e.seq >= call.seq)
1006
- continue;
1007
- if ((e.type === "tool_result" || e.type === "tool_call_end") && e.callId === orig.callId)
1008
- return false;
1009
- }
1010
- return true;
1011
- };
1012
1017
  let durable;
1013
1018
  let legacyAmbiguous = false;
1014
1019
  for (const e of all) {
@@ -1021,7 +1026,7 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
1021
1026
  legacyAmbiguous = true;
1022
1027
  continue;
1023
1028
  }
1024
- if (origSeq === call.seq || reissues(origSeq))
1029
+ if (origSeq === call.seq)
1025
1030
  durable = e;
1026
1031
  }
1027
1032
  let chainVerdict;
@@ -1301,6 +1306,8 @@ export function toStructuredError(err) {
1301
1306
  ...(e.status !== undefined ? { status: e.status } : {}),
1302
1307
  retryable: e.retryable,
1303
1308
  message: typeof e.message === "string" ? e.message : String(err),
1309
+ // CX-1 F8: the provider's Retry-After rides through to the kernel
1310
+ ...(typeof e.retryAfterMs === "number" && Number.isFinite(e.retryAfterMs) && e.retryAfterMs >= 0 ? { retryAfterMs: e.retryAfterMs } : {}),
1304
1311
  };
1305
1312
  }
1306
1313
  }
@@ -471,6 +471,10 @@ export interface StructuredError {
471
471
  readonly status?: number;
472
472
  readonly retryable: boolean;
473
473
  readonly message: string;
474
+ /** CX-1 F8: the provider's `Retry-After`, normalized to milliseconds
475
+ * (finite, >= 0) — the kernel owns retries and honors it; absent when
476
+ * the header was missing, unparseable, or a date already past. */
477
+ readonly retryAfterMs?: number;
474
478
  }
475
479
  /**
476
480
  * Why the whole run ended. The ONE terminal shape every run converges to.
@@ -24,12 +24,31 @@
24
24
  */
25
25
  import type { ToolSpec } from "../protocol/messages.js";
26
26
  import type { Tool } from "./tool.js";
27
+ /** CX-1 F7: a captured tool table — what ONE request advertises and
28
+ * dispatches from. */
29
+ export interface ToolTable {
30
+ readonly specs: readonly ToolSpec[];
31
+ get(name: string): Tool<any> | undefined;
32
+ }
27
33
  export declare class ToolRegistry {
28
34
  #private;
29
35
  register(tool: Tool<any>): void;
30
36
  /** 0.1.26: a live tool source — consulted on every lookup, never
31
37
  * snapshotted. The source returns the CURRENT array (it may grow). */
32
- registerLive(source: () => readonly Tool[]): void;
38
+ registerLive(source: () => readonly Tool[], owner?: string): void;
39
+ /** CX-1 F7 (audit F7) — ONE table per request. The loop takes a
40
+ * snapshot at request assembly and dispatches from it: the definition
41
+ * sent to the model, the schema validated against, the `execute` that
42
+ * runs and the `effects` the scheduler reads are the SAME captured
43
+ * values: the declaration data (schema, effects) is COPIED into the
44
+ * table, the handler stays by reference — an in-place edit to the
45
+ * source after the snapshot cannot reach a request already under way
46
+ * (the 2026-09-07 review's P2: a shallow copy shared the nested
47
+ * objects). Two sources publishing one name is an error the moment
48
+ * it is observed, naming both owners — never a traversal-order pick;
49
+ * the one exception is the SAME Tool object reached twice (the 0.1.27
50
+ * dedup), which is one tool. */
51
+ snapshot(): ToolTable;
33
52
  get(name: string): Tool<any> | undefined;
34
53
  list(): readonly Tool[];
35
54
  has(name: string): boolean;
@@ -33,15 +33,47 @@ export class ToolRegistry {
33
33
  }
34
34
  /** 0.1.26: a live tool source — consulted on every lookup, never
35
35
  * snapshotted. The source returns the CURRENT array (it may grow). */
36
- registerLive(source) {
37
- this.#live.push(source);
36
+ registerLive(source, owner = `live#${this.#live.length + 1}`) {
37
+ this.#live.push({ source, owner });
38
+ }
39
+ /** CX-1 F7 (audit F7) — ONE table per request. The loop takes a
40
+ * snapshot at request assembly and dispatches from it: the definition
41
+ * sent to the model, the schema validated against, the `execute` that
42
+ * runs and the `effects` the scheduler reads are the SAME captured
43
+ * values: the declaration data (schema, effects) is COPIED into the
44
+ * table, the handler stays by reference — an in-place edit to the
45
+ * source after the snapshot cannot reach a request already under way
46
+ * (the 2026-09-07 review's P2: a shallow copy shared the nested
47
+ * objects). Two sources publishing one name is an error the moment
48
+ * it is observed, naming both owners — never a traversal-order pick;
49
+ * the one exception is the SAME Tool object reached twice (the 0.1.27
50
+ * dedup), which is one tool. */
51
+ snapshot() {
52
+ const captured = new Map();
53
+ const take = (t, owner) => {
54
+ const prior = captured.get(t.name);
55
+ if (prior !== undefined) {
56
+ if (prior.original === t)
57
+ return; // the same object reached twice — one tool
58
+ throw new Error(`tool table: '${t.name}' is published by two sources (${prior.owner}, ${owner})`);
59
+ }
60
+ const copy = { ...t, parameters: structuredClone(t.parameters), ...(t.effects === undefined ? {} : { effects: Object.freeze(structuredClone(t.effects)) }) };
61
+ captured.set(t.name, { tool: Object.freeze(copy), original: t, owner });
62
+ };
63
+ for (const t of this.#tools.values())
64
+ take(t, "registered");
65
+ for (const { source, owner } of this.#live)
66
+ for (const t of source())
67
+ take(t, owner);
68
+ const specs = [...captured.values()].map(({ tool: t }) => ({ name: t.name, description: t.description, inputSchema: t.parameters }));
69
+ return { specs, get: (name) => captured.get(name)?.tool };
38
70
  }
39
71
  get(name) {
40
72
  const t = this.#tools.get(name);
41
73
  if (t !== undefined)
42
74
  return t;
43
- for (const src of this.#live) {
44
- const found = src().find((x) => x.name === name);
75
+ for (const { source } of this.#live) {
76
+ const found = source().find((x) => x.name === name);
45
77
  if (found !== undefined)
46
78
  return found;
47
79
  }
@@ -61,8 +93,8 @@ export class ToolRegistry {
61
93
  seen.add(t.name);
62
94
  out.push(t);
63
95
  }
64
- for (const src of this.#live) {
65
- for (const t of src()) {
96
+ for (const { source } of this.#live) {
97
+ for (const t of source()) {
66
98
  if (!seen.has(t.name)) {
67
99
  seen.add(t.name);
68
100
  out.push(t);
@@ -74,7 +106,7 @@ export class ToolRegistry {
74
106
  has(name) {
75
107
  if (this.#tools.has(name))
76
108
  return true;
77
- return this.#live.some((src) => src().some((x) => x.name === name));
109
+ return this.#live.some(({ source }) => source().some((x) => x.name === name));
78
110
  }
79
111
  /** A registry restricted to the named tools. Unknown names are dropped
80
112
  * loudly (the kernel never silently shrinks a tool set). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "kiso (foundation) core — protocol, event log, loop, hooks, modes, permissions, compaction, delivery truth. The 2,000-line kernel at the bottom of the kiso framework.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "openai"
34
34
  ],
35
35
  "devDependencies": {
36
- "@vincemakes/kiso-evals": "0.26.1",
36
+ "@vincemakes/kiso-evals": "0.26.3",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"