@vincemakes/kiso-core 0.15.11 → 0.16.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.
@@ -33,7 +33,7 @@
33
33
  * re-stream that duplicates output or tool calls.
34
34
  */
35
35
  import { type Adapter, type AbortSignalLike } from "../protocol/adapter.js";
36
- import type { Event, StructuredError } from "../protocol/events.js";
36
+ import type { ContinuationScope, Event, StructuredError } from "../protocol/events.js";
37
37
  import type { ApprovalChain } from "../protocol/extension.js";
38
38
  import { EventLog } from "./event-log.js";
39
39
  import type { EventInput } from "./event-log.js";
@@ -53,6 +53,19 @@ export interface LoopConfig {
53
53
  readonly mode?: string;
54
54
  readonly maxTurns?: number;
55
55
  readonly maxRetries?: number;
56
+ /**
57
+ * MG-1 (ADR-0051 Amendment 5): the run's continuation scope — the
58
+ * kernel stamps it onto a committed stop's envelope (adapters cannot
59
+ * forge scope). Absent = an unscoped run (SDK-injected or faux
60
+ * adapters): adapter-emitted continuation is STRIPPED at the commit.
61
+ */
62
+ readonly continuationScope?: ContinuationScope;
63
+ /** XP-1: the RESOLVED reasoning wire values — passed to the adapter
64
+ * verbatim; absent = provider defaults (the byte anchor). */
65
+ readonly reasoning?: {
66
+ readonly thinking?: "adaptive" | "enabled" | "disabled";
67
+ readonly effort?: string;
68
+ };
56
69
  /**
57
70
  * Seed history. When a `log` is provided, the log IS the truth and this
58
71
  * is only used if the log is empty. See ADR-0002 / kernel/project.ts.
@@ -493,6 +493,29 @@ export async function* loop(config) {
493
493
  turnSettled = new Promise((res) => {
494
494
  settleTurn = res;
495
495
  });
496
+ // F4b: the ONE abandon sequence, for EVERY uncommitted exit — the
497
+ // mid-stream error (retryable or not), the user abort, and the
498
+ // voided turn. Settle (parked launches bail on the un-committed
499
+ // gate), drain (started executions land their receipts), then
500
+ // durably void the draft. The defensive check stands: a started
501
+ // commit-required execution in the draft (impossible under
502
+ // invariant 3) suppresses the marker — no void over a started
503
+ // fact, the pre-F4 abandon exactly.
504
+ const unsafeStartedInDraft = () => log.all.some((e) => e.type === "tool_execution_started" && e.seq > turnStart && registry.get(e.name)?.effects?.precommitSafe !== true);
505
+ const abandonDraft = async function* (reason) {
506
+ settleTurn();
507
+ violated = true;
508
+ violatedReject();
509
+ yield* drainSettled();
510
+ if (launchError !== null)
511
+ throw launchError;
512
+ if (log.lastSeq > turnStart && !unsafeStartedInDraft()) {
513
+ const marker = log.append({ type: "model_output_abandoned", voidFromSeq: turnStart, reason });
514
+ if (hooks.onEvent)
515
+ await hooks.onEvent(marker, {}).catch(() => { });
516
+ yield marker;
517
+ }
518
+ };
496
519
  while (true) {
497
520
  // Area 4: the backoff is abortable — a cancel landing during a
498
521
  // retry wait ends the run now, not after the backoff.
@@ -504,6 +527,7 @@ export async function* loop(config) {
504
527
  const stream = config.adapter.stream({
505
528
  model: config.model,
506
529
  messages,
530
+ ...(config.reasoning !== undefined ? { reasoning: config.reasoning } : {}),
507
531
  ...(config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}),
508
532
  tools: registry.toSpecs(),
509
533
  ...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
@@ -577,8 +601,12 @@ export async function* loop(config) {
577
601
  catch (err) {
578
602
  // Area 4: a user cancel surfaced by the SDK (APIUserAbortError
579
603
  // or any error while the signal is set) is an honest `aborted`
580
- // terminal, never a generic error.
604
+ // terminal, never a generic error. F4b: the aborted draft is
605
+ // voided LIVE — pre-F4b only a resume voided it, so the same
606
+ // durable prefix projected differently depending on whether
607
+ // the process crashed first.
581
608
  if (aborted()) {
609
+ yield* abandonDraft("the run was aborted before this turn committed");
582
610
  yield await terminal({ kind: "aborted", by: "user" });
583
611
  return;
584
612
  }
@@ -591,42 +619,17 @@ export async function* loop(config) {
591
619
  await sleep(attempts * 250, signal); // abortable backoff
592
620
  continue;
593
621
  }
594
- // F4 — ABANDON HYGIENE, every streamed exit: the attempt settles
595
- // (parked launches bail on the un-committed gate, started ones
596
- // land their receipts), the draft is durably voided, and only
597
- // THEN a retry or the terminal. Pre-F4 this path returned with
598
- // the draft un-voided under the terminal — the next request
599
- // projected it as committed history (ADR-0047 Gap B, live), and
600
- // a dangling tool_call_end fed the provider-400 class (EC1-F1).
601
- settleTurn();
602
- violated = true;
603
- violatedReject();
604
- yield* drainSettled();
605
- if (launchError !== null)
606
- throw launchError;
607
- // Invariant 3 makes a started commit-required execution in the
608
- // draft structurally impossible (the turn never committed) —
609
- // CHECKED, not assumed: if one ever exists, fall back to the
610
- // pre-F4 abandon (no marker over a started fact, no retry).
611
- const unsafeStarted = log.all.some((e) => e.type === "tool_execution_started" && e.seq > turnStart && registry.get(e.name)?.effects?.precommitSafe !== true);
612
- if (log.lastSeq > turnStart && !unsafeStarted) {
613
- // The loop's THIRD producer of `model_output_abandoned` (an
614
- // existing variant — no new protocol surface): broader than
615
- // the live void's dangling-call condition, because the SAME
616
- // process re-requests immediately and an un-voided text
617
- // draft would glue onto the retried stream's projection.
618
- const marker = log.append({
619
- type: "model_output_abandoned",
620
- voidFromSeq: turnStart,
621
- reason: "the provider stream failed before this turn committed",
622
- });
623
- if (hooks.onEvent)
624
- await hooks.onEvent(marker, {}).catch(() => { });
625
- yield marker;
626
- }
622
+ // F4 — ABANDON HYGIENE, every streamed exit: settle, drain,
623
+ // durably void (the loop's third `model_output_abandoned`
624
+ // producer; text-only drafts included), and only THEN a retry
625
+ // or the terminal. Pre-F4 this path returned with the draft
626
+ // un-voided under the terminal — the next request projected it
627
+ // as committed history (ADR-0047 Gap B, live), and a dangling
628
+ // tool_call_end fed the provider-400 class (EC1-F1).
629
+ yield* abandonDraft("the provider stream failed before this turn committed");
627
630
  // F4 — the mid-stream retry: same classification, same per-turn
628
631
  // budget (ADR-0005 Amendment 1: frame state, per-process).
629
- if (structured.retryable && attempts < maxRetries && !unsafeStarted) {
632
+ if (structured.retryable && attempts < maxRetries && !unsafeStartedInDraft()) {
630
633
  attempts += 1;
631
634
  await sleep(attempts * 250, signal); // the same abortable backoff
632
635
  // Fresh per-attempt state — the marker is the boundary now.
@@ -725,6 +728,22 @@ export async function* loop(config) {
725
728
  break;
726
729
  }
727
730
  }
731
+ // ── MG-1 (A5): the trust boundary's half of the envelope ─────────
732
+ // Stamp, strip, cap — BEFORE the commit decision, so a hard-cap
733
+ // violation voids the turn (no durable stop persists that is known
734
+ // unable to continue correctly; the F4b abandon voids its draft).
735
+ if (voided === null && heldStop !== null && heldStop.continuation !== undefined) {
736
+ const prepared = prepareContinuation(heldStop, config.continuationScope);
737
+ if (prepared === "hard-cap") {
738
+ voided = {
739
+ kind: "error",
740
+ error: { code: "invalid_request", retryable: false, message: "the turn's required continuation metadata exceeds the hard cap" },
741
+ };
742
+ }
743
+ else {
744
+ heldStop = prepared;
745
+ }
746
+ }
728
747
  // ── EC-1 ① — TURN COMMIT ──────────────────────────────────────────
729
748
  // The held stop is persisted HERE and only here: iterator done (the
730
749
  // stream loop broke cleanly) AND structurally compatible (above). The
@@ -745,7 +764,11 @@ export async function* loop(config) {
745
764
  // NO calls has nothing to abandon and still ends on its own stop
746
765
  // reason, the pre-EC-1 order.
747
766
  if (voided === null && pending.length > 0 && aborted()) {
748
- settleTurn();
767
+ // F4b: the abandoned calls are voided LIVE — the resume derives
768
+ // the same void from the same prefix, so live and post-crash
769
+ // projections agree, and no dangling tool_use reaches the next
770
+ // request in either world.
771
+ yield* abandonDraft("the run was aborted before this turn committed");
749
772
  yield await terminal({ kind: "aborted", by: "user" });
750
773
  return;
751
774
  }
@@ -773,43 +796,28 @@ export async function* loop(config) {
773
796
  // the in-between gaps (mid-handler launches, the ask pause:
774
797
  // never a busy spin, never a deadlock on a pending ack).
775
798
  if (voided !== null) {
776
- violated = true;
777
- violatedReject();
799
+ // EC-1 ⑤ — THE LIVE VOID, F4b: the shared abandon sequence. ①
800
+ // means a voided turn's commit-required call never ran, so nothing
801
+ // answers the `tool_use` its tool_call_end already persisted; the
802
+ // run ends on its terminal and the recovery driver never sees it
803
+ // (its first rule is "the open run reached its terminal") — the
804
+ // provider-400 class, live rather than after a crash. The marker
805
+ // is the instrument the resume already uses, produced here (an
806
+ // existing variant — no new protocol surface). F4b broadened the
807
+ // condition from dangling-calls-only to ANY draft: a text-only
808
+ // voided draft glued onto the next request exactly the way the
809
+ // mid-stream cut's did. A started commit-required call (a FACT)
810
+ // still suppresses the marker — abandonDraft's standing check,
811
+ // the same rule as recovery-plan.ts's `unexecuted`. Idempotent by
812
+ // construction: the marker becomes the last boundary, so no later
813
+ // resume derives a second draft over the same range.
814
+ yield* abandonDraft("the turn was voided before it committed");
815
+ yield await terminal(voided);
816
+ return;
778
817
  }
779
818
  yield* drainSettled();
780
819
  if (launchError !== null)
781
820
  throw launchError;
782
- if (voided !== null) {
783
- // EC-1 ⑤ — THE LIVE VOID. ① means a voided turn's commit-required
784
- // call never ran, so nothing answers the `tool_use` its
785
- // tool_call_end already persisted. The run ends here on its error
786
- // terminal, and the recovery driver will never see it: its first
787
- // rule is "the open run reached its terminal". So the NEXT turn of
788
- // the same session would send the model an assistant tool_use with
789
- // no result — the provider-400 class, live rather than after a
790
- // crash. Pre-EC-1 the streaming launch had already answered the
791
- // pair; closing the destructive hole opened this one.
792
- //
793
- // The fix is the instrument the resume already uses, produced here
794
- // instead: the loop is a SECOND PRODUCER of `model_output_abandoned`
795
- // (an existing variant — no new protocol surface, the frozen event
796
- // contract holds). It voids the whole draft range, exactly as
797
- // ABANDON_DRAFT does, and only when a call is still pure intent —
798
- // a call with a durable started is a FACT, and the same rule as
799
- // recovery-plan.ts's `unexecuted`. Idempotent by construction: the
800
- // marker becomes the last boundary, so no later resume derives a
801
- // second draft over the same range.
802
- if (log.all.some((e) => e.type === "tool_call_end" &&
803
- e.seq > turnStart &&
804
- !log.all.some((x) => x.type === "tool_execution_started" && x.callId === e.callId))) {
805
- const marker = log.append({ type: "model_output_abandoned", voidFromSeq: turnStart, reason: "the turn was voided before it committed" });
806
- if (hooks.onEvent)
807
- await hooks.onEvent(marker, {}).catch(() => { });
808
- yield marker;
809
- }
810
- yield await terminal(voided);
811
- return;
812
- }
813
821
  // ── Advance history: the log grew; re-derive for the next turn ─────
814
822
  messages = derive();
815
823
  }
@@ -1282,3 +1290,39 @@ const NEVER_ABORT = {
1282
1290
  addEventListener: () => { },
1283
1291
  removeEventListener: () => { },
1284
1292
  };
1293
+ // ── MG-1 (ADR-0051 Amendment 5): the envelope preparation ────────────────
1294
+ //
1295
+ // Pure: stamp the run's scope over whatever the adapter claimed (adapters
1296
+ // are not trusted), strip on an unscoped run, and enforce the two caps —
1297
+ // optional entries drop WHOLE at the soft cap (earliest first, emission
1298
+ // order) with a durable `truncated: true`; a required set over the hard
1299
+ // cap is a provider-contract violation the caller turns into a voided
1300
+ // turn. Sizes are UTF-8 bytes of the serialized entry.
1301
+ const CONTINUATION_SOFT_CAP = 256 * 1024;
1302
+ const CONTINUATION_HARD_CAP = 2 * 1024 * 1024;
1303
+ function prepareContinuation(stop, scope) {
1304
+ const c = stop.continuation;
1305
+ if (c === undefined)
1306
+ return stop;
1307
+ if (scope === undefined) {
1308
+ const { continuation: _stripped, ...rest } = stop;
1309
+ return rest;
1310
+ }
1311
+ const size = (e) => new TextEncoder().encode(e.data).length + new TextEncoder().encode(e.kind).length + 32;
1312
+ const requiredBytes = c.entries.filter((e) => e.required).reduce((n, e) => n + size(e), 0);
1313
+ if (requiredBytes > CONTINUATION_HARD_CAP)
1314
+ return "hard-cap";
1315
+ const kept = [...c.entries];
1316
+ let total = kept.reduce((n, e) => n + size(e), 0);
1317
+ let truncated = false;
1318
+ for (let i = 0; total > CONTINUATION_SOFT_CAP && i < kept.length;) {
1319
+ if (kept[i].required) {
1320
+ i += 1;
1321
+ continue;
1322
+ }
1323
+ total -= size(kept[i]);
1324
+ kept.splice(i, 1);
1325
+ truncated = true;
1326
+ }
1327
+ return { ...stop, continuation: { scope, entries: kept, ...(truncated ? { truncated: true } : {}) } };
1328
+ }
@@ -87,20 +87,28 @@ export function projectMessages(events) {
87
87
  // `thinking` events and attached to the assistant message at flush —
88
88
  // deterministic (same events → same messages → same request body, D area).
89
89
  let pendingReasoning = null;
90
+ // MG-1 (A5): the committed stop's continuation envelope, attached to the
91
+ // SAME flush its stop triggers — only a durable stop carries one, so a
92
+ // voided draft can never leak stale continuation (invariant ③).
93
+ let pendingContinuation = null;
90
94
  const flushAssistant = () => {
91
95
  pushText();
92
96
  if (blocks.length === 0) {
93
97
  assistantSource = undefined;
98
+ pendingContinuation = null;
94
99
  return;
95
100
  }
96
101
  const callIds = blocks.filter((b) => b.type === "tool_use").map((b) => b.callId);
97
102
  const reasoning = pendingReasoning;
98
103
  pendingReasoning = null;
104
+ const continuation = pendingContinuation;
105
+ pendingContinuation = null;
99
106
  out.push({
100
107
  role: "assistant",
101
108
  blocks: [...blocks],
102
109
  ...(assistantSource !== undefined ? { source: assistantSource } : {}),
103
110
  ...(reasoning !== null ? { reasoning } : {}),
111
+ ...(continuation !== null ? { continuation } : {}),
104
112
  });
105
113
  blocks = [];
106
114
  assistantSource = undefined;
@@ -159,12 +167,17 @@ export function projectMessages(events) {
159
167
  // disjoint and in seq order; the skip below treats them exactly like the
160
168
  // summary ranges (the marker itself renders nothing and skips itself).
161
169
  // R-E 0.1.44 (the void scope sentence): the void range voids MODEL
162
- // OUTPUT only — text_delta / thinking / tool_call_* — never the
163
- // framework's facts (permission*, tool_execution, tool_result,
164
- // user_input, terminal). The summary ranges keep their blanket reach
165
- // (the summary replaces everything it covers).
170
+ // OUTPUT only — never the framework's facts (permission*,
171
+ // tool_execution, tool_result, user_input, terminal). The summary
172
+ // ranges keep their blanket reach (the summary replaces everything it
173
+ // covers). F4b: the block boundaries text_start/text_end ARE model
174
+ // output and join the family — a voided text_start carries the
175
+ // draft's provenance (`source`), and leaving it alive relabeled the
176
+ // RETRIED answer with the abandoned attempt's source.
166
177
  const MODEL_OUTPUT_TYPES = new Set([
178
+ "text_start",
167
179
  "text_delta",
180
+ "text_end",
168
181
  "thinking",
169
182
  "tool_call_start",
170
183
  "tool_call_input_delta",
@@ -501,6 +514,9 @@ export function projectMessages(events) {
501
514
  // projects [assistant, results…] in reading order, and the
502
515
  // stream-open guards below only ever flush a CLOSED turn's
503
516
  // late (post-stop) results.
517
+ // MG-1 (A5): the committed stop hands its envelope to the
518
+ // flush it triggers; every other flush site carries none.
519
+ pendingContinuation = ev.continuation ?? null;
504
520
  flushAssistant();
505
521
  flushResults();
506
522
  break;
@@ -46,6 +46,13 @@ export interface StreamOptions {
46
46
  readonly tools?: readonly ToolSpec[];
47
47
  readonly maxTokens?: number;
48
48
  readonly temperature?: number;
49
+ /** XP-1: the RESOLVED reasoning setting — native wire values only (the
50
+ * runtime's matrix resolves and refuses; adapters serialize per
51
+ * dialect). Absent = provider defaults, byte-identical to pre-XP-1. */
52
+ readonly reasoning?: {
53
+ readonly thinking?: "adaptive" | "enabled" | "disabled";
54
+ readonly effort?: string;
55
+ };
49
56
  readonly signal?: AbortSignalLike;
50
57
  }
51
58
  /**
@@ -417,10 +417,43 @@ export interface Usage {
417
417
  readonly cacheWrite: number | null;
418
418
  readonly known: boolean;
419
419
  }
420
+ /** MG-1 (ADR-0051 Amendment 5): the continuation envelope's scope — WHO
421
+ * may replay it. Kernel-stamped at the Turn Commit append from the run's
422
+ * configured binding; an adapter-supplied scope is always overwritten
423
+ * (adapters are not trusted), and a run with no configured scope has
424
+ * adapter-emitted continuation stripped at the same boundary. */
425
+ export interface ContinuationScope {
426
+ readonly providerId: string;
427
+ readonly apiId: string;
428
+ readonly modelId: string;
429
+ /** Origin only; REQUIRED when providerId === "custom". */
430
+ readonly endpoint?: string;
431
+ }
432
+ /** One opaque provider block. `data` is bytes to the kernel — serialized
433
+ * verbatim by the emitting adapter, replayed verbatim by the scope-matched
434
+ * one, never reconstructed from projected text. */
435
+ export interface ContinuationEntry {
436
+ readonly kind: string;
437
+ /** true = the next request is INVALID without it (never dropped; a
438
+ * required set over the hard cap voids the turn before commit).
439
+ * false = quality-degradable (droppable under the soft cap). */
440
+ readonly required: boolean;
441
+ readonly data: string;
442
+ }
443
+ export interface Continuation {
444
+ readonly scope: ContinuationScope;
445
+ /** EMISSION ORDER, preserved end to end. */
446
+ readonly entries: readonly ContinuationEntry[];
447
+ /** Present only when OPTIONAL entries were dropped at the soft cap. */
448
+ readonly truncated?: true;
449
+ }
420
450
  export interface Stop {
421
451
  readonly seq: number;
422
452
  readonly type: "stop";
423
453
  readonly reason: StopReason;
454
+ /** MG-1 (Amendment 5): absent on every pre-A5 log — rule 1's truly
455
+ * optional field; old logs project byte-identically. */
456
+ readonly continuation?: Continuation;
424
457
  }
425
458
  /**
426
459
  * Structured failure classification for MODEL / TRANSPORT errors.
@@ -143,6 +143,33 @@ function isTerminal(v) {
143
143
  * safe integer; the others are null when the provider did not report
144
144
  * them.
145
145
  */
146
+ /** A5: shape-checked ONLY when present — rule 1's truly-optional clause.
147
+ * The key set stays open, so older bins load newer logs unchanged. */
148
+ function isContinuation(v) {
149
+ if (v === undefined)
150
+ return true;
151
+ if (typeof v !== "object" || v === null)
152
+ return false;
153
+ const c = v;
154
+ const s = c.scope;
155
+ if (typeof s !== "object" || s === null)
156
+ return false;
157
+ const sc = s;
158
+ if (typeof sc.providerId !== "string" || typeof sc.apiId !== "string" || typeof sc.modelId !== "string")
159
+ return false;
160
+ if (sc.endpoint !== undefined && typeof sc.endpoint !== "string")
161
+ return false;
162
+ if (!Array.isArray(c.entries))
163
+ return false;
164
+ for (const e of c.entries) {
165
+ if (typeof e !== "object" || e === null)
166
+ return false;
167
+ const r = e;
168
+ if (typeof r.kind !== "string" || typeof r.required !== "boolean" || typeof r.data !== "string")
169
+ return false;
170
+ }
171
+ return c.truncated === undefined || c.truncated === true;
172
+ }
146
173
  function isUsage(v) {
147
174
  if (typeof v.known !== "boolean")
148
175
  return false;
@@ -176,7 +203,7 @@ const EVENT_VALIDATORS = {
176
203
  isTags(v) && isInvocationSeq(v),
177
204
  thinking: (v) => typeof v.text === "string",
178
205
  usage: isUsage,
179
- stop: (v) => STOP_REASONS.has(v.reason),
206
+ stop: (v) => STOP_REASONS.has(v.reason) && isContinuation(v.continuation),
180
207
  user_input: (v) => isContent(v.content) && isSource(v),
181
208
  compacted: (v) => Array.isArray(v.cleared) &&
182
209
  v.cleared.every((c) => isPlainObject(c) &&
@@ -82,6 +82,13 @@ export interface AssistantMessage {
82
82
  * follow-up requests). Present only when the turn actually reasoned.
83
83
  */
84
84
  readonly reasoning?: string;
85
+ /**
86
+ * MG-1 (ADR-0051 Amendment 5): the turn's committed continuation
87
+ * envelope, derived from its stop. Opaque to everything but the
88
+ * scope-matched adapter; messages are the versioned model-request
89
+ * side (Amendment 3(a)), never the frozen plane.
90
+ */
91
+ readonly continuation?: import("./events.js").Continuation;
85
92
  }
86
93
  /**
87
94
  * Sent back to the model after a tool ran.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.15.11",
3
+ "version": "0.16.0",
4
4
  "description": "kiso (foundation) core \u2014 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.15.11",
36
+ "@vincemakes/kiso-evals": "0.16.0",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"