@vincemakes/kiso-core 0.15.10 → 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.
- package/dist/kernel/loop.js +115 -53
- package/dist/kernel/project.js +9 -4
- package/package.json +2 -2
package/dist/kernel/loop.js
CHANGED
|
@@ -193,18 +193,37 @@ export async function* loop(config) {
|
|
|
193
193
|
// the launch records it and the loop re-throws it after the settle
|
|
194
194
|
// (same propagation the sequential execute had).
|
|
195
195
|
let launchError = null;
|
|
196
|
+
/** Drain until every launch settles: the receipts land (the write-ahead
|
|
197
|
+
* acks resolve during the pump); the 10ms poll covers mid-handler gaps
|
|
198
|
+
* and the ask pause — never a busy spin, never a deadlock on an ack.
|
|
199
|
+
* One settle semantics, two callers: the turn settle and F4's abandon. */
|
|
200
|
+
const drainSettled = async function* () {
|
|
201
|
+
while (execActive > 0) {
|
|
202
|
+
yield* drainExec();
|
|
203
|
+
await sleep(10);
|
|
204
|
+
}
|
|
205
|
+
yield* drainExec();
|
|
206
|
+
};
|
|
196
207
|
let violated = false;
|
|
197
208
|
// The violated signal: rejects when the turn is voided — the paused
|
|
198
209
|
// ask-branches bail (abort semantics for not-started executions). Typed
|
|
199
210
|
// `never` so the ask race resolves to the human decision alone.
|
|
211
|
+
// F4: PER-ATTEMPT state now — a mid-stream retry voids one attempt and
|
|
212
|
+
// continues, so the signal re-arms after each abandon (pre-F4 a void
|
|
213
|
+
// always ended the run and one signal per run sufficed).
|
|
200
214
|
let violatedReject = () => { };
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
let violatedP;
|
|
216
|
+
const armVoidSignal = () => {
|
|
217
|
+
violated = false;
|
|
218
|
+
violatedP = new Promise((_, reject) => {
|
|
219
|
+
violatedReject = () => reject();
|
|
220
|
+
});
|
|
221
|
+
// The turn's ask-branches race it; a turn with NO ask leaves the
|
|
222
|
+
// rejection un-consumed — the no-op keeps it from surfacing as an
|
|
223
|
+
// unhandled rejection while the race consumers still receive it.
|
|
224
|
+
void violatedP.catch(() => { });
|
|
225
|
+
};
|
|
226
|
+
armVoidSignal();
|
|
208
227
|
// ── EC-1 ① — the DURABLE TURN COMMIT gate ──────────────────────────────
|
|
209
228
|
// Invariant 3 (COMMIT GATING): a commit-required handler never starts
|
|
210
229
|
// before this turn's stop is DURABLE. The gate RESOLVES exactly once per
|
|
@@ -466,13 +485,37 @@ export async function* loop(config) {
|
|
|
466
485
|
// EC-1 ⑤ (the live void): the last durable event BEFORE this turn's
|
|
467
486
|
// model output — the previous turn's stop, the user input, or a
|
|
468
487
|
// microcompact boundary. Everything after it is this turn's draft,
|
|
469
|
-
// which is exactly the range a void must abandon.
|
|
470
|
-
|
|
488
|
+
// which is exactly the range a void must abandon. F4: `let` — a
|
|
489
|
+
// mid-stream retry moves the boundary to its abandonment marker.
|
|
490
|
+
let turnStart = log.lastSeq;
|
|
471
491
|
// EC-1 ①: this turn's commit gate — reset BEFORE any call can launch.
|
|
472
492
|
committed = false;
|
|
473
493
|
turnSettled = new Promise((res) => {
|
|
474
494
|
settleTurn = res;
|
|
475
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
|
+
};
|
|
476
519
|
while (true) {
|
|
477
520
|
// Area 4: the backoff is abortable — a cancel landing during a
|
|
478
521
|
// retry wait ends the run now, not after the backoff.
|
|
@@ -557,19 +600,55 @@ export async function* loop(config) {
|
|
|
557
600
|
catch (err) {
|
|
558
601
|
// Area 4: a user cancel surfaced by the SDK (APIUserAbortError
|
|
559
602
|
// or any error while the signal is set) is an honest `aborted`
|
|
560
|
-
// 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.
|
|
561
607
|
if (aborted()) {
|
|
608
|
+
yield* abandonDraft("the run was aborted before this turn committed");
|
|
562
609
|
yield await terminal({ kind: "aborted", by: "user" });
|
|
563
610
|
return;
|
|
564
611
|
}
|
|
565
612
|
const structured = toStructuredError(err);
|
|
566
|
-
// Phase B: never
|
|
567
|
-
//
|
|
613
|
+
// Phase B: never SILENTLY re-stream a turn that already emitted
|
|
614
|
+
// content — duplicates are worse than failures. Nothing streamed
|
|
615
|
+
// yet: the cheap in-place retry (no draft exists to void).
|
|
568
616
|
if (structured.retryable && !streamed && attempts < maxRetries) {
|
|
569
617
|
attempts += 1;
|
|
570
618
|
await sleep(attempts * 250, signal); // abortable backoff
|
|
571
619
|
continue;
|
|
572
620
|
}
|
|
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");
|
|
629
|
+
// F4 — the mid-stream retry: same classification, same per-turn
|
|
630
|
+
// budget (ADR-0005 Amendment 1: frame state, per-process).
|
|
631
|
+
if (structured.retryable && attempts < maxRetries && !unsafeStartedInDraft()) {
|
|
632
|
+
attempts += 1;
|
|
633
|
+
await sleep(attempts * 250, signal); // the same abortable backoff
|
|
634
|
+
// Fresh per-attempt state — the marker is the boundary now.
|
|
635
|
+
pending.length = 0;
|
|
636
|
+
lastStop = undefined;
|
|
637
|
+
stopCount = 0;
|
|
638
|
+
sawStop = false;
|
|
639
|
+
postStopViolation = false;
|
|
640
|
+
forgedEvent = false;
|
|
641
|
+
duplicateStop = false;
|
|
642
|
+
heldStop = null;
|
|
643
|
+
streamed = false;
|
|
644
|
+
committed = false;
|
|
645
|
+
turnSettled = new Promise((res) => {
|
|
646
|
+
settleTurn = res;
|
|
647
|
+
});
|
|
648
|
+
armVoidSignal();
|
|
649
|
+
turnStart = log.lastSeq;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
573
652
|
yield await terminal({ kind: "error", error: structured });
|
|
574
653
|
return;
|
|
575
654
|
}
|
|
@@ -668,7 +747,11 @@ export async function* loop(config) {
|
|
|
668
747
|
// NO calls has nothing to abandon and still ends on its own stop
|
|
669
748
|
// reason, the pre-EC-1 order.
|
|
670
749
|
if (voided === null && pending.length > 0 && aborted()) {
|
|
671
|
-
|
|
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");
|
|
672
755
|
yield await terminal({ kind: "aborted", by: "user" });
|
|
673
756
|
return;
|
|
674
757
|
}
|
|
@@ -696,49 +779,28 @@ export async function* loop(config) {
|
|
|
696
779
|
// the in-between gaps (mid-handler launches, the ask pause:
|
|
697
780
|
// never a busy spin, never a deadlock on a pending ack).
|
|
698
781
|
if (voided !== null) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
// terminal, and the recovery driver will never see it: its first
|
|
716
|
-
// rule is "the open run reached its terminal". So the NEXT turn of
|
|
717
|
-
// the same session would send the model an assistant tool_use with
|
|
718
|
-
// no result — the provider-400 class, live rather than after a
|
|
719
|
-
// crash. Pre-EC-1 the streaming launch had already answered the
|
|
720
|
-
// pair; closing the destructive hole opened this one.
|
|
721
|
-
//
|
|
722
|
-
// The fix is the instrument the resume already uses, produced here
|
|
723
|
-
// instead: the loop is a SECOND PRODUCER of `model_output_abandoned`
|
|
724
|
-
// (an existing variant — no new protocol surface, the frozen event
|
|
725
|
-
// contract holds). It voids the whole draft range, exactly as
|
|
726
|
-
// ABANDON_DRAFT does, and only when a call is still pure intent —
|
|
727
|
-
// a call with a durable started is a FACT, and the same rule as
|
|
728
|
-
// recovery-plan.ts's `unexecuted`. Idempotent by construction: the
|
|
729
|
-
// marker becomes the last boundary, so no later resume derives a
|
|
730
|
-
// second draft over the same range.
|
|
731
|
-
if (log.all.some((e) => e.type === "tool_call_end" &&
|
|
732
|
-
e.seq > turnStart &&
|
|
733
|
-
!log.all.some((x) => x.type === "tool_execution_started" && x.callId === e.callId))) {
|
|
734
|
-
const marker = log.append({ type: "model_output_abandoned", voidFromSeq: turnStart, reason: "the turn was voided before it committed" });
|
|
735
|
-
if (hooks.onEvent)
|
|
736
|
-
await hooks.onEvent(marker, {}).catch(() => { });
|
|
737
|
-
yield marker;
|
|
738
|
-
}
|
|
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");
|
|
739
798
|
yield await terminal(voided);
|
|
740
799
|
return;
|
|
741
800
|
}
|
|
801
|
+
yield* drainSettled();
|
|
802
|
+
if (launchError !== null)
|
|
803
|
+
throw launchError;
|
|
742
804
|
// ── Advance history: the log grew; re-derive for the next turn ─────
|
|
743
805
|
messages = derive();
|
|
744
806
|
}
|
package/dist/kernel/project.js
CHANGED
|
@@ -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 —
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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.
|
|
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.
|
|
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"
|