@vincemakes/kiso-core 0.15.11 → 0.15.12

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.
@@ -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.
@@ -577,8 +600,12 @@ export async function* loop(config) {
577
600
  catch (err) {
578
601
  // Area 4: a user cancel surfaced by the SDK (APIUserAbortError
579
602
  // or any error while the signal is set) is an honest `aborted`
580
- // terminal, never a generic error.
603
+ // terminal, never a generic error. F4b: the aborted draft is
604
+ // voided LIVE — pre-F4b only a resume voided it, so the same
605
+ // durable prefix projected differently depending on whether
606
+ // the process crashed first.
581
607
  if (aborted()) {
608
+ yield* abandonDraft("the run was aborted before this turn committed");
582
609
  yield await terminal({ kind: "aborted", by: "user" });
583
610
  return;
584
611
  }
@@ -591,42 +618,17 @@ export async function* loop(config) {
591
618
  await sleep(attempts * 250, signal); // abortable backoff
592
619
  continue;
593
620
  }
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
- }
621
+ // F4 — ABANDON HYGIENE, every streamed exit: settle, drain,
622
+ // durably void (the loop's third `model_output_abandoned`
623
+ // producer; text-only drafts included), and only THEN a retry
624
+ // or the terminal. Pre-F4 this path returned with the draft
625
+ // un-voided under the terminal — the next request projected it
626
+ // as committed history (ADR-0047 Gap B, live), and a dangling
627
+ // tool_call_end fed the provider-400 class (EC1-F1).
628
+ yield* abandonDraft("the provider stream failed before this turn committed");
627
629
  // F4 — the mid-stream retry: same classification, same per-turn
628
630
  // budget (ADR-0005 Amendment 1: frame state, per-process).
629
- if (structured.retryable && attempts < maxRetries && !unsafeStarted) {
631
+ if (structured.retryable && attempts < maxRetries && !unsafeStartedInDraft()) {
630
632
  attempts += 1;
631
633
  await sleep(attempts * 250, signal); // the same abortable backoff
632
634
  // Fresh per-attempt state — the marker is the boundary now.
@@ -745,7 +747,11 @@ export async function* loop(config) {
745
747
  // NO calls has nothing to abandon and still ends on its own stop
746
748
  // reason, the pre-EC-1 order.
747
749
  if (voided === null && pending.length > 0 && aborted()) {
748
- settleTurn();
750
+ // F4b: the abandoned calls are voided LIVE — the resume derives
751
+ // the same void from the same prefix, so live and post-crash
752
+ // projections agree, and no dangling tool_use reaches the next
753
+ // request in either world.
754
+ yield* abandonDraft("the run was aborted before this turn committed");
749
755
  yield await terminal({ kind: "aborted", by: "user" });
750
756
  return;
751
757
  }
@@ -773,43 +779,28 @@ export async function* loop(config) {
773
779
  // the in-between gaps (mid-handler launches, the ask pause:
774
780
  // never a busy spin, never a deadlock on a pending ack).
775
781
  if (voided !== null) {
776
- violated = true;
777
- violatedReject();
782
+ // EC-1 ⑤ — THE LIVE VOID, F4b: the shared abandon sequence. ①
783
+ // means a voided turn's commit-required call never ran, so nothing
784
+ // answers the `tool_use` its tool_call_end already persisted; the
785
+ // run ends on its terminal and the recovery driver never sees it
786
+ // (its first rule is "the open run reached its terminal") — the
787
+ // provider-400 class, live rather than after a crash. The marker
788
+ // is the instrument the resume already uses, produced here (an
789
+ // existing variant — no new protocol surface). F4b broadened the
790
+ // condition from dangling-calls-only to ANY draft: a text-only
791
+ // voided draft glued onto the next request exactly the way the
792
+ // mid-stream cut's did. A started commit-required call (a FACT)
793
+ // still suppresses the marker — abandonDraft's standing check,
794
+ // the same rule as recovery-plan.ts's `unexecuted`. Idempotent by
795
+ // construction: the marker becomes the last boundary, so no later
796
+ // resume derives a second draft over the same range.
797
+ yield* abandonDraft("the turn was voided before it committed");
798
+ yield await terminal(voided);
799
+ return;
778
800
  }
779
801
  yield* drainSettled();
780
802
  if (launchError !== null)
781
803
  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
804
  // ── Advance history: the log grew; re-derive for the next turn ─────
814
805
  messages = derive();
815
806
  }
@@ -159,12 +159,17 @@ export function projectMessages(events) {
159
159
  // disjoint and in seq order; the skip below treats them exactly like the
160
160
  // summary ranges (the marker itself renders nothing and skips itself).
161
161
  // 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).
162
+ // OUTPUT only — never the framework's facts (permission*,
163
+ // tool_execution, tool_result, user_input, terminal). The summary
164
+ // ranges keep their blanket reach (the summary replaces everything it
165
+ // covers). F4b: the block boundaries text_start/text_end ARE model
166
+ // output and join the family — a voided text_start carries the
167
+ // draft's provenance (`source`), and leaving it alive relabeled the
168
+ // RETRIED answer with the abandoned attempt's source.
166
169
  const MODEL_OUTPUT_TYPES = new Set([
170
+ "text_start",
167
171
  "text_delta",
172
+ "text_end",
168
173
  "thinking",
169
174
  "tool_call_start",
170
175
  "tool_call_input_delta",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.15.11",
3
+ "version": "0.15.12",
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.15.12",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"