@vincemakes/kiso-core 0.26.0 → 0.26.2

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,28 @@ 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 wait is
645
+ // max(attempts × 250 ms, the provider's Retry-After), abortable.
646
+ // A wait above the cap is never shortened — the run stops with
647
+ // an explicit error instead of retrying early.
648
+ const retryWait = () => {
649
+ const ms = Math.max((attempts + 1) * 250, structured.retryAfterMs ?? 0);
650
+ return ms > RETRY_AFTER_MAX_MS ? null : ms;
651
+ };
652
+ if (structured.retryable && attempts < maxRetries && retryWait() === null) {
653
+ structured = {
654
+ ...structured,
655
+ retryable: false,
656
+ message: `${structured.message} (the provider asked to wait ${structured.retryAfterMs}ms — beyond the ${RETRY_AFTER_MAX_MS}ms cap; not retried)`,
657
+ };
658
+ }
634
659
  // Phase B: never SILENTLY re-stream a turn that already emitted
635
660
  // content — duplicates are worse than failures. Nothing streamed
636
661
  // yet: the cheap in-place retry (no draft exists to void).
637
662
  if (structured.retryable && !streamed && attempts < maxRetries) {
638
663
  attempts += 1;
639
- await sleep(attempts * 250, signal); // abortable backoff
664
+ await sleep(retryWait() ?? attempts * 250, signal); // abortable backoff, Retry-After honored
640
665
  continue;
641
666
  }
642
667
  // F4 — ABANDON HYGIENE, every streamed exit: settle, drain,
@@ -651,7 +676,7 @@ export async function* loop(config) {
651
676
  // budget (ADR-0005 Amendment 1: frame state, per-process).
652
677
  if (structured.retryable && attempts < maxRetries && !unsafeStartedInDraft()) {
653
678
  attempts += 1;
654
- await sleep(attempts * 250, signal); // the same abortable backoff
679
+ await sleep(retryWait() ?? attempts * 250, signal); // the same abortable backoff, Retry-After honored
655
680
  // Fresh per-attempt state — the marker is the boundary now.
656
681
  pending.length = 0;
657
682
  lastStop = undefined;
@@ -959,12 +984,76 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
959
984
  // its verdict. A throwing chain counts as ask. Allow/deny are recorded
960
985
  // durably with decidedBy = the deciding extension, never pausing for a
961
986
  // human.
962
- const originalCall = [...log.all].reverse().find((e) => e.type === "tool_call_end" && e.callId === call.callId);
963
- const durable = originalCall !== undefined && JSON.stringify(originalCall.input) === JSON.stringify(call.input)
964
- ? log.all.find((e) => e.type === "permission_decided" && e.decidedBy !== undefined && e.callId === call.callId)
965
- : undefined;
987
+ // CX-1 F1 (audit F1): a durable decision is consumable ONCE — by the
988
+ // invocation it was recorded for, or by that invocation's RE-ISSUE on
989
+ // resume (same callId, same tool, identical input, and the original
990
+ // never produced a result). A provider callId alone is correlation,
991
+ // never identity: the old lookup found the current call itself
992
+ // (already appended), compared its input to its own, and took the
993
+ // FIRST decision with that callId in the whole log — so a reused
994
+ // callId inherited an old allow for a new call.
995
+ //
996
+ // The decision's invocationSeq names the original call (every
997
+ // producer stamps it, round 5). A legacy decision (no invocationSeq,
998
+ // pre-round-5 logs) binds to the nearest PRECEDING same-callId call
999
+ // before it — call first, decision after — with no run boundary
1000
+ // between; one that binds to nothing is ambiguous, and the call goes
1001
+ // to ask, never to the chain's allow.
1002
+ const all = log.all;
1003
+ const legacyBinding = (d) => {
1004
+ for (let i = all.length - 1; i >= 0; i -= 1) {
1005
+ const e = all[i];
1006
+ if (e.seq >= d.seq)
1007
+ continue;
1008
+ if (e.type === "terminal")
1009
+ return undefined;
1010
+ if (e.type === "tool_call_end" && e.callId === call.callId)
1011
+ return e.seq;
1012
+ }
1013
+ return undefined;
1014
+ };
1015
+ /** The re-issue rule: a resume re-issues the pending call under the SAME
1016
+ * provider id — same callId, same tool, identical input — and the
1017
+ * original never completed: no result and no other call under that id
1018
+ * stands between the original and this call. A different callId with
1019
+ * the same arguments is a new invocation (the /mode-switch shape: a
1020
+ * write denied under plan is asked afresh under default). */
1021
+ const reissues = (origSeq) => {
1022
+ const orig = all.find((e) => e.seq === origSeq);
1023
+ if (orig === undefined || orig.type !== "tool_call_end")
1024
+ return false;
1025
+ if (orig.callId !== call.callId || orig.name !== call.name)
1026
+ return false;
1027
+ if (JSON.stringify(orig.input) !== JSON.stringify(call.input))
1028
+ return false;
1029
+ for (const e of all) {
1030
+ if (e.seq <= origSeq || e.seq >= call.seq)
1031
+ continue;
1032
+ if ((e.type === "tool_result" || e.type === "tool_call_end") && e.callId === orig.callId)
1033
+ return false;
1034
+ }
1035
+ return true;
1036
+ };
1037
+ let durable;
1038
+ let legacyAmbiguous = false;
1039
+ for (const e of all) {
1040
+ if (e.type !== "permission_decided" || e.decidedBy === undefined)
1041
+ continue;
1042
+ if (e.invocationSeq === undefined && e.callId !== call.callId)
1043
+ continue;
1044
+ const origSeq = e.invocationSeq ?? legacyBinding(e);
1045
+ if (origSeq === undefined) {
1046
+ legacyAmbiguous = true;
1047
+ continue;
1048
+ }
1049
+ if (origSeq === call.seq || reissues(origSeq))
1050
+ durable = e;
1051
+ }
966
1052
  let chainVerdict;
967
- if (durable === undefined && approvalPolicy !== undefined) {
1053
+ if (durable === undefined && legacyAmbiguous) {
1054
+ chainVerdict = { action: "ask" }; // the conservative reading of an old log
1055
+ }
1056
+ else if (durable === undefined && approvalPolicy !== undefined) {
968
1057
  try {
969
1058
  chainVerdict = await raceAbort(Promise.resolve(approvalPolicy.decide(payload, ctx)), signal);
970
1059
  }
@@ -1237,6 +1326,8 @@ export function toStructuredError(err) {
1237
1326
  ...(e.status !== undefined ? { status: e.status } : {}),
1238
1327
  retryable: e.retryable,
1239
1328
  message: typeof e.message === "string" ? e.message : String(err),
1329
+ // CX-1 F8: the provider's Retry-After rides through to the kernel
1330
+ ...(typeof e.retryAfterMs === "number" && Number.isFinite(e.retryAfterMs) && e.retryAfterMs >= 0 ? { retryAfterMs: e.retryAfterMs } : {}),
1240
1331
  };
1241
1332
  }
1242
1333
  }
@@ -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,29 @@
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 (a shallow copy of each Tool — extensions replace their Tool
44
+ * objects rather than mutating them, and the copy makes that a
45
+ * contract). Two sources publishing one name is an error the moment
46
+ * it is observed, naming both owners — never a traversal-order pick;
47
+ * the one exception is the SAME Tool object reached twice (the 0.1.27
48
+ * dedup), which is one tool. */
49
+ snapshot(): ToolTable;
33
50
  get(name: string): Tool<any> | undefined;
34
51
  list(): readonly Tool[];
35
52
  has(name: string): boolean;
@@ -33,15 +33,44 @@ 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 (a shallow copy of each Tool — extensions replace their Tool
44
+ * objects rather than mutating them, and the copy makes that a
45
+ * contract). Two sources publishing one name is an error the moment
46
+ * it is observed, naming both owners — never a traversal-order pick;
47
+ * the one exception is the SAME Tool object reached twice (the 0.1.27
48
+ * dedup), which is one tool. */
49
+ snapshot() {
50
+ const captured = new Map();
51
+ const take = (t, owner) => {
52
+ const prior = captured.get(t.name);
53
+ if (prior !== undefined) {
54
+ if (prior.original === t)
55
+ return; // the same object reached twice — one tool
56
+ throw new Error(`tool table: '${t.name}' is published by two sources (${prior.owner}, ${owner})`);
57
+ }
58
+ captured.set(t.name, { tool: Object.freeze({ ...t }), original: t, owner });
59
+ };
60
+ for (const t of this.#tools.values())
61
+ take(t, "registered");
62
+ for (const { source, owner } of this.#live)
63
+ for (const t of source())
64
+ take(t, owner);
65
+ const specs = [...captured.values()].map(({ tool: t }) => ({ name: t.name, description: t.description, inputSchema: t.parameters }));
66
+ return { specs, get: (name) => captured.get(name)?.tool };
38
67
  }
39
68
  get(name) {
40
69
  const t = this.#tools.get(name);
41
70
  if (t !== undefined)
42
71
  return t;
43
- for (const src of this.#live) {
44
- const found = src().find((x) => x.name === name);
72
+ for (const { source } of this.#live) {
73
+ const found = source().find((x) => x.name === name);
45
74
  if (found !== undefined)
46
75
  return found;
47
76
  }
@@ -61,8 +90,8 @@ export class ToolRegistry {
61
90
  seen.add(t.name);
62
91
  out.push(t);
63
92
  }
64
- for (const src of this.#live) {
65
- for (const t of src()) {
93
+ for (const { source } of this.#live) {
94
+ for (const t of source()) {
66
95
  if (!seen.has(t.name)) {
67
96
  seen.add(t.name);
68
97
  out.push(t);
@@ -74,7 +103,7 @@ export class ToolRegistry {
74
103
  has(name) {
75
104
  if (this.#tools.has(name))
76
105
  return true;
77
- return this.#live.some((src) => src().some((x) => x.name === name));
106
+ return this.#live.some(({ source }) => source().some((x) => x.name === name));
78
107
  }
79
108
  /** A registry restricted to the named tools. Unknown names are dropped
80
109
  * 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.0",
3
+ "version": "0.26.2",
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.0",
36
+ "@vincemakes/kiso-evals": "0.26.2",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"