@vincemakes/kiso-core 0.12.0 → 0.14.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.
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/kernel/loop.js +310 -98
- package/dist/kernel/project.js +7 -3
- package/dist/tools/tool.d.ts +35 -2
- package/dist/tools/tool.js +3 -2
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/kernel/loop.js
CHANGED
|
@@ -205,11 +205,33 @@ export async function* loop(config) {
|
|
|
205
205
|
// rejection un-consumed — the no-op keeps it from surfacing as an
|
|
206
206
|
// unhandled rejection while the race consumers still receive it.
|
|
207
207
|
void violatedP.catch(() => { });
|
|
208
|
+
// ── EC-1 ① — the DURABLE TURN COMMIT gate ──────────────────────────────
|
|
209
|
+
// Invariant 3 (COMMIT GATING): a commit-required handler never starts
|
|
210
|
+
// before this turn's stop is DURABLE. The gate RESOLVES exactly once per
|
|
211
|
+
// turn — at the commit, or at the void — and the waiter then reads
|
|
212
|
+
// `committed`. It deliberately does not reuse `violatedP`: that one
|
|
213
|
+
// REJECTS, and a rejection awaited here would be caught by the launch's
|
|
214
|
+
// catch and recorded as a launchError, failing the whole run for what is
|
|
215
|
+
// an ordinary void. A gate that resolves keeps the void path quiet.
|
|
216
|
+
// Both are reset per turn (below): turn N+1's calls wait for turn N+1's
|
|
217
|
+
// commit. Safe because the settle drains every launch before the turn
|
|
218
|
+
// advances, so no launch ever waits on a stale gate.
|
|
219
|
+
let committed = false;
|
|
220
|
+
let settleTurn = () => { };
|
|
221
|
+
let turnSettled = new Promise((res) => {
|
|
222
|
+
settleTurn = res;
|
|
223
|
+
});
|
|
208
224
|
// The ask gate: an ask's human resolution blocks the calls after it.
|
|
209
225
|
// The DECIDE chain serializes the decisions in CALL order, so the gate
|
|
210
226
|
// an ask installs is structurally in place before the successors decide.
|
|
227
|
+
// EC-1 ③: it must be installed THERE and not at the ask itself, even
|
|
228
|
+
// though the ask now happens later (post-commit): the gate is what a
|
|
229
|
+
// precommit-eligible sibling consults to know an ask was accepted ahead
|
|
230
|
+
// of it, and that has to be knowable BEFORE the commit. Each call
|
|
231
|
+
// captures the gate ahead of it and its own release (below) — one shared
|
|
232
|
+
// `askRelease` used to mean the first ask's resolution opened the SECOND
|
|
233
|
+
// ask's gate.
|
|
211
234
|
let askGate = Promise.resolve();
|
|
212
|
-
let askRelease = null;
|
|
213
235
|
// The decision chain: each launch's decide is chained onto the previous
|
|
214
236
|
// one's — the decides run in CALL order and the chain resolves to the
|
|
215
237
|
// call's verdict.
|
|
@@ -219,16 +241,57 @@ export async function* loop(config) {
|
|
|
219
241
|
// keys (the executionId comes from the drain, seq-stable).
|
|
220
242
|
let idSeq = log.all.length + 1;
|
|
221
243
|
const nextDecisionId = () => `d-${idSeq++}`;
|
|
244
|
+
// ── EC-1 ② / ③ — the FIFO EXCLUSIVE BARRIER ────────────────────────────
|
|
245
|
+
// Absence is the conservative truth: a tool that declares nothing is
|
|
246
|
+
// EXCLUSIVE, and the kernel serializes it. `concurrency: "shared"` is the
|
|
247
|
+
// only way to overlap, and it is a per-TOOL certificate the kernel
|
|
248
|
+
// enforces — never a per-call claim it could not police.
|
|
249
|
+
//
|
|
250
|
+
// The fence is installed at ACCEPTANCE (in `launch`, i.e. CALL order),
|
|
251
|
+
// not when a handler starts: that is what makes it FIFO. A later sibling
|
|
252
|
+
// — including a precommit-safe read — never overtakes an exclusive
|
|
253
|
+
// invocation accepted before it, so a read can never observe a
|
|
254
|
+
// half-written file. A read after a write therefore loses its latency
|
|
255
|
+
// win; safe overtaking and snapshot semantics stay future work.
|
|
256
|
+
let fence = Promise.resolve();
|
|
257
|
+
const sharedRunning = [];
|
|
258
|
+
/** Reserve this call's place in the FIFO: what it must await before
|
|
259
|
+
* running, and the release to call once its handler is done. */
|
|
260
|
+
const reserve = (tool) => {
|
|
261
|
+
const shared = tool?.effects?.concurrency === "shared";
|
|
262
|
+
let release;
|
|
263
|
+
const done = new Promise((res) => {
|
|
264
|
+
release = res;
|
|
265
|
+
});
|
|
266
|
+
// An exclusive invocation waits for the fence AND every shared call
|
|
267
|
+
// already accepted ahead of it — while it runs, it runs alone.
|
|
268
|
+
const wait = shared ? fence : Promise.all([fence, ...sharedRunning]).then(() => undefined);
|
|
269
|
+
if (shared)
|
|
270
|
+
sharedRunning.push(done);
|
|
271
|
+
else {
|
|
272
|
+
fence = done;
|
|
273
|
+
sharedRunning.length = 0;
|
|
274
|
+
}
|
|
275
|
+
return { wait, release };
|
|
276
|
+
};
|
|
222
277
|
const launch = (call) => {
|
|
223
278
|
execActive += 1;
|
|
279
|
+
const tool = registry.get(call.name);
|
|
280
|
+
const slot = reserve(tool);
|
|
281
|
+
// EC-1 ③ — this call's place in the ASK order, filled in by the
|
|
282
|
+
// decide chain (call order). `ahead` is the gate of an ask ACCEPTED
|
|
283
|
+
// BEFORE this call; `release` opens this call's own gate for its
|
|
284
|
+
// successors and stays a no-op unless this call asks. Per-call, so
|
|
285
|
+
// two asks in one turn queue honestly instead of sharing one release.
|
|
286
|
+
const askOrder = { ahead: Promise.resolve(), release: () => { } };
|
|
224
287
|
launches.push((async () => {
|
|
225
288
|
try {
|
|
226
|
-
await acquireWindow();
|
|
227
289
|
decideChain = decideChain.then(async () => {
|
|
228
290
|
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);
|
|
291
|
+
askOrder.ahead = askGate;
|
|
229
292
|
if (v.action === "ask") {
|
|
230
293
|
askGate = new Promise((res) => {
|
|
231
|
-
|
|
294
|
+
askOrder.release = res;
|
|
232
295
|
});
|
|
233
296
|
}
|
|
234
297
|
return v;
|
|
@@ -240,33 +303,94 @@ export async function* loop(config) {
|
|
|
240
303
|
pushExec(verdict.result);
|
|
241
304
|
return;
|
|
242
305
|
}
|
|
306
|
+
// the conservative order: the calls AFTER an ask wait for its
|
|
307
|
+
// human resolution. The context may have changed when the
|
|
308
|
+
// human approves — which is why even a precommit-safe read
|
|
309
|
+
// accepted after an ask waits here rather than racing ahead.
|
|
310
|
+
await askOrder.ahead;
|
|
311
|
+
if (violated)
|
|
312
|
+
return;
|
|
243
313
|
if (verdict.action === "ask") {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
314
|
+
try {
|
|
315
|
+
// EC-1 ③ — THE POST-COMMIT ASK. A human must never
|
|
316
|
+
// be asked to approve a call whose turn then proves
|
|
317
|
+
// invalid, so the pause waits for this turn's OWN
|
|
318
|
+
// commit exactly like a handler does. On an
|
|
319
|
+
// uncommitted turn the call bails here having asked
|
|
320
|
+
// NOTHING and started nothing. (Before EC-1 the
|
|
321
|
+
// question was put to a person mid-stream, and a
|
|
322
|
+
// provider that violated the protocol after its stop
|
|
323
|
+
// made that person's "yes" authorize a turn the
|
|
324
|
+
// kernel then voided.)
|
|
325
|
+
await turnSettled;
|
|
326
|
+
if (!committed)
|
|
327
|
+
return;
|
|
328
|
+
// The human pause — abortable by a user abort OR a
|
|
329
|
+
// turn void (the violated promise). Post-commit a
|
|
330
|
+
// void can no longer reach this branch; the race
|
|
331
|
+
// stays as the cheap structural guarantee that it
|
|
332
|
+
// never could.
|
|
333
|
+
const decision = await Promise.race([
|
|
334
|
+
humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec, verdict.speaker),
|
|
335
|
+
violatedP,
|
|
336
|
+
]);
|
|
337
|
+
if (violated)
|
|
338
|
+
return;
|
|
339
|
+
if (decision.action !== "allow") {
|
|
340
|
+
// the reason rides the denial — the human's words,
|
|
341
|
+
// or the honest "no approval flow configured".
|
|
342
|
+
pushExec(resultEvent(call, denialResult(decision.reason ?? "denied")));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
262
345
|
}
|
|
346
|
+
finally {
|
|
347
|
+
// The gate opens the moment the human's part is over
|
|
348
|
+
// — whatever the outcome, and on every bail path:
|
|
349
|
+
// successors wait for the VERDICT, never for this
|
|
350
|
+
// call's handler, and a turn that voids before the
|
|
351
|
+
// ask must not strand them.
|
|
352
|
+
askOrder.release();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (tool?.effects?.precommitSafe !== true) {
|
|
356
|
+
// EC-1 ① (invariant 3, COMMIT GATING): the handler waits
|
|
357
|
+
// for this turn's OWN commit. An uncommitted turn never
|
|
358
|
+
// reaches a handler — the call bails here having emitted
|
|
359
|
+
// NO started event, so it is clean, never uncertain
|
|
360
|
+
// (abort semantics). This is the whole of "an invalid
|
|
361
|
+
// turn never starts a commit-required tool handler".
|
|
362
|
+
//
|
|
363
|
+
// EC-1 ③(b) — THE PRECOMMIT LAUNCH RULE is the `else`
|
|
364
|
+
// this branch is guarded by: a call skips the commit
|
|
365
|
+
// gate iff its tool declares `precommitSafe` AND its
|
|
366
|
+
// authorization is ALREADY satisfied — an `allow`
|
|
367
|
+
// verdict, no human in the loop. Both halves are
|
|
368
|
+
// necessary: the certificate says the EXECUTION is
|
|
369
|
+
// harmless (read-only, free, local, universally), never
|
|
370
|
+
// that the authorization is unnecessary. Such a call may
|
|
371
|
+
// run on a turn that later voids; invariant 7 owns that
|
|
372
|
+
// outcome — the receipt is an honest fact and the turn
|
|
373
|
+
// stays uncommitted.
|
|
374
|
+
await turnSettled;
|
|
375
|
+
if (!committed)
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// EC-1 ③ (invariants 5 + 6): wait behind the FIFO barrier,
|
|
379
|
+
// and only THEN take a window slot. Waiting — for the
|
|
380
|
+
// commit, for a human, or for the barrier — consumes no
|
|
381
|
+
// slot: the window is an EXECUTION window, not a
|
|
382
|
+
// pending-invocation window, so four held writes can never
|
|
383
|
+
// starve a runnable sibling.
|
|
384
|
+
await slot.wait;
|
|
385
|
+
if (violated)
|
|
386
|
+
return;
|
|
387
|
+
await acquireWindow();
|
|
388
|
+
try {
|
|
389
|
+
await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
|
|
390
|
+
}
|
|
391
|
+
finally {
|
|
392
|
+
releaseWindow();
|
|
263
393
|
}
|
|
264
|
-
// the conservative order: the calls AFTER an ask wait for its human
|
|
265
|
-
// resolution (the askGate is the ask's pause promise —
|
|
266
|
-
// resolved by default, released by the ask branch above).
|
|
267
|
-
// The context may have changed when the human approves.
|
|
268
|
-
await askGate;
|
|
269
|
-
await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
|
|
270
394
|
}
|
|
271
395
|
catch (err) {
|
|
272
396
|
// The abort sentinel (a user cancel during the decide or
|
|
@@ -277,7 +401,12 @@ export async function* loop(config) {
|
|
|
277
401
|
launchError ??= err;
|
|
278
402
|
}
|
|
279
403
|
finally {
|
|
280
|
-
|
|
404
|
+
// The barrier and the ask gate are released on EVERY exit —
|
|
405
|
+
// a denial, an uncommitted turn, an abort — or the siblings
|
|
406
|
+
// queued behind this call would wait forever. (Releasing an
|
|
407
|
+
// already-open gate is a no-op: a promise resolves once.)
|
|
408
|
+
askOrder.release();
|
|
409
|
+
slot.release();
|
|
281
410
|
execActive -= 1;
|
|
282
411
|
}
|
|
283
412
|
})());
|
|
@@ -325,6 +454,25 @@ export async function* loop(config) {
|
|
|
325
454
|
// user_input, …) from the stream is a FORGERY and must never reach
|
|
326
455
|
// the log.
|
|
327
456
|
let forgedEvent = false;
|
|
457
|
+
// EC-1 ①: a SECOND stop voids the turn — and unlike the pre-EC-1 path
|
|
458
|
+
// (which appended both, leaving two stops in the log forever), NEITHER
|
|
459
|
+
// stop is persisted now. A deliberate improvement of that class.
|
|
460
|
+
let duplicateStop = false;
|
|
461
|
+
// EC-1 ①: the turn's stop, HELD in memory until the stream proves
|
|
462
|
+
// clean. It is neither appended NOR yielded here — yield order must
|
|
463
|
+
// equal append order (persistence-ownership.test.ts), so the hold
|
|
464
|
+
// covers both, and the commit below performs both together.
|
|
465
|
+
let heldStop = null;
|
|
466
|
+
// EC-1 ⑤ (the live void): the last durable event BEFORE this turn's
|
|
467
|
+
// model output — the previous turn's stop, the user input, or a
|
|
468
|
+
// microcompact boundary. Everything after it is this turn's draft,
|
|
469
|
+
// which is exactly the range a void must abandon.
|
|
470
|
+
const turnStart = log.lastSeq;
|
|
471
|
+
// EC-1 ①: this turn's commit gate — reset BEFORE any call can launch.
|
|
472
|
+
committed = false;
|
|
473
|
+
turnSettled = new Promise((res) => {
|
|
474
|
+
settleTurn = res;
|
|
475
|
+
});
|
|
328
476
|
while (true) {
|
|
329
477
|
// Area 4: the backoff is abortable — a cancel landing during a
|
|
330
478
|
// retry wait ends the run now, not after the backoff.
|
|
@@ -370,9 +518,24 @@ export async function* loop(config) {
|
|
|
370
518
|
break;
|
|
371
519
|
}
|
|
372
520
|
if (ev.type === "stop") {
|
|
521
|
+
// EC-1 ① — DURABLE TURN COMMIT. The stop is HELD, not
|
|
522
|
+
// persisted: before EC-1 it was appended the instant it
|
|
523
|
+
// arrived, so a stop could be durable while the stream
|
|
524
|
+
// was still running — and the recovery keyed "this turn
|
|
525
|
+
// committed" on exactly that durable stop. The two
|
|
526
|
+
// truths are now one: a durable compatible stop means
|
|
527
|
+
// THIS producer observed clean stream exhaustion.
|
|
528
|
+
if (sawStop) {
|
|
529
|
+
duplicateStop = true;
|
|
530
|
+
violated = true;
|
|
531
|
+
violatedReject();
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
373
534
|
sawStop = true;
|
|
374
535
|
lastStop = ev.reason;
|
|
375
536
|
stopCount += 1;
|
|
537
|
+
heldStop = ev;
|
|
538
|
+
continue;
|
|
376
539
|
}
|
|
377
540
|
// R-E 0.1.43: the append precedes the launch — the call's
|
|
378
541
|
// framework seq is assigned here; invocationSeq must never
|
|
@@ -430,78 +593,98 @@ export async function* loop(config) {
|
|
|
430
593
|
error: { code: "invalid_request", retryable: false, message: "provider emitted events after its stop event" },
|
|
431
594
|
};
|
|
432
595
|
}
|
|
433
|
-
else if (
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
596
|
+
else if (duplicateStop) {
|
|
597
|
+
// EC-1 ①: a second stop. The pre-EC-1 path appended BOTH and voided
|
|
598
|
+
// afterwards, so a duplicate-stop turn left two stops in the log
|
|
599
|
+
// forever; now neither is durable — the turn simply never commits.
|
|
600
|
+
voided = {
|
|
601
|
+
kind: "error",
|
|
602
|
+
error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount + 1} stop events in one turn` },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
else if (stopCount === 0) {
|
|
606
|
+
// Area 6: protocol anomalies are STRUCTURED ERRORS, never a default
|
|
607
|
+
// `completed`. EC-1 ①: ONE arm now, not two — the pre-EC-1 code
|
|
608
|
+
// duplicated this verdict across the pending/no-pending split, where
|
|
609
|
+
// it never differed.
|
|
610
|
+
voided = {
|
|
611
|
+
kind: "error",
|
|
612
|
+
error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
else if (pending.length > 0) {
|
|
616
|
+
// C group: STRUCTURAL COMPATIBILITY — a stop reason that cannot
|
|
617
|
+
// carry tool calls voids the turn. EC-1 ①: this is now the second
|
|
618
|
+
// half of the commit condition and runs BEFORE the commit, so an
|
|
619
|
+
// incompatible turn never persists its stop; combined with the
|
|
620
|
+
// commit gate its calls never became effects either (pre-EC-1 they
|
|
621
|
+
// had already launched, and the void could only let their receipts
|
|
622
|
+
// land after the fact).
|
|
623
|
+
switch (lastStop) {
|
|
624
|
+
case "tool_use":
|
|
625
|
+
case "function_call":
|
|
626
|
+
break; // compatible with complete calls — the executions proceed
|
|
627
|
+
case "max_tokens":
|
|
628
|
+
voided = { kind: "max_tokens" };
|
|
629
|
+
break;
|
|
630
|
+
case "abort":
|
|
631
|
+
voided = { kind: "aborted", by: "user" };
|
|
632
|
+
break;
|
|
633
|
+
case "error":
|
|
634
|
+
voided = {
|
|
635
|
+
kind: "error",
|
|
636
|
+
error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
|
|
637
|
+
};
|
|
638
|
+
break;
|
|
639
|
+
default:
|
|
640
|
+
voided = {
|
|
641
|
+
kind: "error",
|
|
642
|
+
error: {
|
|
643
|
+
code: "invalid_request",
|
|
644
|
+
retryable: false,
|
|
645
|
+
message: `provider stopped with '${String(lastStop)}' with ${pending.length} tool call(s) launched`,
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
break;
|
|
452
649
|
}
|
|
453
650
|
}
|
|
454
|
-
// ──
|
|
455
|
-
//
|
|
456
|
-
|
|
651
|
+
// ── EC-1 ① — TURN COMMIT ──────────────────────────────────────────
|
|
652
|
+
// The held stop is persisted HERE and only here: iterator done (the
|
|
653
|
+
// stream loop broke cleanly) AND structurally compatible (above). The
|
|
654
|
+
// append and the yield happen together, in the loop's ordinary order,
|
|
655
|
+
// so the durable log gains no event and loses none — only the MOMENT
|
|
656
|
+
// moves, and the projection is byte-identical. What it buys:
|
|
657
|
+
//
|
|
658
|
+
// a durable compatible stop ⇔ this producer observed clean stream
|
|
659
|
+
// exhaustion.
|
|
660
|
+
//
|
|
661
|
+
// The gate is released either way: a committed turn frees its
|
|
662
|
+
// handlers, a voided turn frees them to bail with no started event.
|
|
663
|
+
// An abort with calls in flight abandons the turn BEFORE it commits:
|
|
664
|
+
// the aborted turn leaves NO durable stop, so its calls stay an
|
|
665
|
+
// UNCOMMITTED DRAFT the resume voids (⑤) rather than a committed batch
|
|
666
|
+
// no one will ever execute — which would strand the model's tool_use
|
|
667
|
+
// blocks with no results (the EC1-F1 dangling-pair class). A turn with
|
|
668
|
+
// NO calls has nothing to abandon and still ends on its own stop
|
|
669
|
+
// reason, the pre-EC-1 order.
|
|
670
|
+
if (voided === null && pending.length > 0 && aborted()) {
|
|
671
|
+
settleTurn();
|
|
457
672
|
yield await terminal({ kind: "aborted", by: "user" });
|
|
458
673
|
return;
|
|
459
674
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
switch (lastStop) {
|
|
478
|
-
case "tool_use":
|
|
479
|
-
case "function_call":
|
|
480
|
-
break; // compatible with complete calls — the executions proceed
|
|
481
|
-
case "max_tokens":
|
|
482
|
-
voided = { kind: "max_tokens" };
|
|
483
|
-
break;
|
|
484
|
-
case "abort":
|
|
485
|
-
voided = { kind: "aborted", by: "user" };
|
|
486
|
-
break;
|
|
487
|
-
case "error":
|
|
488
|
-
voided = {
|
|
489
|
-
kind: "error",
|
|
490
|
-
error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
|
|
491
|
-
};
|
|
492
|
-
break;
|
|
493
|
-
default:
|
|
494
|
-
voided = {
|
|
495
|
-
kind: "error",
|
|
496
|
-
error: {
|
|
497
|
-
code: "invalid_request",
|
|
498
|
-
retryable: false,
|
|
499
|
-
message: `provider stopped with '${String(lastStop)}' with ${pending.length} tool call(s) launched`,
|
|
500
|
-
},
|
|
501
|
-
};
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
675
|
+
if (voided === null && heldStop !== null) {
|
|
676
|
+
const full = log.append(heldStop);
|
|
677
|
+
if (hooks.onEvent)
|
|
678
|
+
await hooks.onEvent(full, {}).catch(() => { });
|
|
679
|
+
committed = true;
|
|
680
|
+
yield full;
|
|
681
|
+
}
|
|
682
|
+
settleTurn();
|
|
683
|
+
// A turn with no tool calls ends on its OWN stop reason (Phase B,
|
|
684
|
+
// Area 6) — never a blanket `completed`. Reached only once committed.
|
|
685
|
+
if (voided === null && pending.length === 0) {
|
|
686
|
+
yield await terminal(terminalForStop(lastStop));
|
|
687
|
+
return;
|
|
505
688
|
}
|
|
506
689
|
// ── The turn settles: a void fires the violated signal — the
|
|
507
690
|
// not-started executions bail (abort semantics — no started, no
|
|
@@ -526,6 +709,33 @@ export async function* loop(config) {
|
|
|
526
709
|
if (launchError !== null)
|
|
527
710
|
throw launchError;
|
|
528
711
|
if (voided !== null) {
|
|
712
|
+
// EC-1 ⑤ — THE LIVE VOID. ① means a voided turn's commit-required
|
|
713
|
+
// call never ran, so nothing answers the `tool_use` its
|
|
714
|
+
// tool_call_end already persisted. The run ends here on its error
|
|
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
|
+
}
|
|
529
739
|
yield await terminal(voided);
|
|
530
740
|
return;
|
|
531
741
|
}
|
|
@@ -826,7 +1036,7 @@ async function runLedgered(call, registry, hooks, ctx, signal, push) {
|
|
|
826
1036
|
// observes ctx.signal, but the gate itself must not invoke it after
|
|
827
1037
|
// a cancel.
|
|
828
1038
|
result = signal?.aborted
|
|
829
|
-
? { content: "aborted before execution", isError: true, errorKind: "
|
|
1039
|
+
? { content: "aborted before execution", isError: true, errorKind: "precondition" }
|
|
830
1040
|
: await tool.execute(call.input, ctx);
|
|
831
1041
|
}
|
|
832
1042
|
catch (err) {
|
|
@@ -844,8 +1054,10 @@ async function runLedgered(call, registry, hooks, ctx, signal, push) {
|
|
|
844
1054
|
// receipt below, losslessly — a crash-window repair of the tool_result
|
|
845
1055
|
// reproduces the normal path). Idempotent failures carry no note; the
|
|
846
1056
|
// MCP bridge maps tools without declaring idempotency, so unknown
|
|
847
|
-
// idempotency = the note applies (honest).
|
|
848
|
-
|
|
1057
|
+
// idempotency = the note applies (honest). WR-1-F1: a PRECONDITION
|
|
1058
|
+
// refusal is work refused BEFORE it starts — by the kind's own
|
|
1059
|
+
// contract nothing ran, so the note would be a false statement there.
|
|
1060
|
+
if (result.isError && result.errorKind !== "precondition" && tool.idempotent !== true) {
|
|
849
1061
|
result = {
|
|
850
1062
|
...result,
|
|
851
1063
|
content: `${result.content}\n[non-idempotent tool failed — its side effects may have partially applied; verify before retrying]`,
|
package/dist/kernel/project.js
CHANGED
|
@@ -518,9 +518,13 @@ export function projectMessages(events) {
|
|
|
518
518
|
// its own range; the case documents the intent (and no flush:
|
|
519
519
|
// it must never split a message).
|
|
520
520
|
break;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
521
|
+
// EC-1 (ruled in at the checkpoint): a SECOND `case
|
|
522
|
+
// "microcompacted"` sat here and was unreachable — the earlier
|
|
523
|
+
// case at the top of this switch owns the label, and in a
|
|
524
|
+
// JavaScript switch the first matching case wins. It never ran,
|
|
525
|
+
// it emitted a build warning ("this case clause will never be
|
|
526
|
+
// evaluated"), and it cost two lines of the kernel's budget.
|
|
527
|
+
// Removed; the microcompact replacement pass above is unchanged.
|
|
524
528
|
}
|
|
525
529
|
}
|
|
526
530
|
flushAssistant();
|
package/dist/tools/tool.d.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* Their absence is the honest contract; the concurrency RACE they were
|
|
12
12
|
* mistaken for a defense against is a real open question and moved to EC-1,
|
|
13
13
|
* which owes a mechanism that does not depend on a per-tool opt-in.
|
|
14
|
-
* Delivery truth is named by the CALLER (
|
|
15
|
-
*
|
|
14
|
+
* Delivery truth is named by the CALLER (`DeliveryConfig.producers`), which
|
|
15
|
+
* is where it always actually lived — and since EC-1 that verdict lives in
|
|
16
|
+
* kiso-evals (governance/delivery.ts there), not in the kernel at all.
|
|
16
17
|
*
|
|
17
18
|
* WHY JSON Schema instead of a runtime library: the kernel has zero runtime
|
|
18
19
|
* dependencies (ADR-0001). Zod / TypeBox / valibot live at the harness layer;
|
|
@@ -79,6 +80,38 @@ export interface Tool<I = unknown> {
|
|
|
79
80
|
* applies. A retry is a NEW call and re-passes the approval chain.
|
|
80
81
|
*/
|
|
81
82
|
readonly idempotent?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* EC-1 — an OPTIMIZATION CERTIFICATE, never a safety claim.
|
|
85
|
+
*
|
|
86
|
+
* Read the type carefully: there is no `"exclusive"`, and there is no
|
|
87
|
+
* `false`. ABSENCE is the conservative truth — an undeclared tool is
|
|
88
|
+
* commit-required and exclusive — so the type system cannot express a
|
|
89
|
+
* claim that something unsafe is safe. Correctness comes from absence;
|
|
90
|
+
* a declaration only buys performance back. That is the whole reason
|
|
91
|
+
* this field is shaped so oddly, and it is the lesson SC-1b paid for:
|
|
92
|
+
* `concurrencySafe` and `delivers` were declarations the kernel never
|
|
93
|
+
* read, so they were fiction. The kernel ENFORCES both of these, in the
|
|
94
|
+
* same round that introduces them.
|
|
95
|
+
*
|
|
96
|
+
* precommitSafe — "running this before the turn commits is harmless:
|
|
97
|
+
* read-only AND free AND local, for EVERY invocation." Only such a
|
|
98
|
+
* call may start before Turn Commit, and only when its authorization
|
|
99
|
+
* is already satisfied (auto-allowed). Its execution never makes an
|
|
100
|
+
* uncommitted turn valid (invariant 7).
|
|
101
|
+
*
|
|
102
|
+
* concurrency: "shared" — "EVERY invocation may overlap every
|
|
103
|
+
* sibling." Without it the kernel serializes the tool behind a FIFO
|
|
104
|
+
* exclusive barrier, which is what closes the same-path write race:
|
|
105
|
+
* two edit_file calls on one path can no longer interleave.
|
|
106
|
+
*
|
|
107
|
+
* Per-call conflict granularity (a resourceKey) stays future and
|
|
108
|
+
* evidence-gated: this field is per-TOOL on purpose, because a per-call
|
|
109
|
+
* claim is exactly the kind the type system could not police.
|
|
110
|
+
*/
|
|
111
|
+
readonly effects?: {
|
|
112
|
+
readonly precommitSafe?: true;
|
|
113
|
+
readonly concurrency?: "shared";
|
|
114
|
+
};
|
|
82
115
|
/** R-C: ONE line for the system prompt — the tool's role, never the
|
|
83
116
|
* schema (the full description rides the JSON schema the provider
|
|
84
117
|
* transmits anyway — never pay twice). */
|
package/dist/tools/tool.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* Their absence is the honest contract; the concurrency RACE they were
|
|
12
12
|
* mistaken for a defense against is a real open question and moved to EC-1,
|
|
13
13
|
* which owes a mechanism that does not depend on a per-tool opt-in.
|
|
14
|
-
* Delivery truth is named by the CALLER (
|
|
15
|
-
*
|
|
14
|
+
* Delivery truth is named by the CALLER (`DeliveryConfig.producers`), which
|
|
15
|
+
* is where it always actually lived — and since EC-1 that verdict lives in
|
|
16
|
+
* kiso-evals (governance/delivery.ts there), not in the kernel at all.
|
|
16
17
|
*
|
|
17
18
|
* WHY JSON Schema instead of a runtime library: the kernel has zero runtime
|
|
18
19
|
* dependencies (ADR-0001). Zod / TypeBox / valibot live at the harness layer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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.
|
|
36
|
+
"@vincemakes/kiso-evals": "0.14.0",
|
|
37
37
|
"@types/node": "^26.1.2",
|
|
38
38
|
"typescript": "^5.7.2",
|
|
39
39
|
"vitest": "^3.0.0"
|