@vincemakes/kiso-runtime 0.1.34 → 0.1.35

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 CHANGED
@@ -5,5 +5,6 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./ledger.js";
8
9
  export * from "./extensions.js";
9
10
  export * from "./trust.js";
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ export * from "./recovery.js";
5
5
  export * from "./compose.js";
6
6
  export * from "./summarize.js";
7
7
  export * from "./store.js";
8
+ export * from "./ledger.js";
8
9
  export * from "./extensions.js";
9
10
  export * from "./trust.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * L2 — the execution ledger: exactly-once side effects from the event log.
3
+ *
4
+ * Every tool execution writes `tool_execution_started` before the handler
5
+ * and `tool_execution_succeeded` / `tool_execution_failed` after
6
+ * (kernel/loop.ts). From those events alone — no second store — this module
7
+ * answers the recovery questions:
8
+ *
9
+ * 1. What is the durable status of execution X? (`executionLedger`)
10
+ * 2. What is the latest execution of call Y? (`executionForCallId`)
11
+ *
12
+ * IDENTITY (Area 3): the ledger is keyed by `executionId` — a persistent,
13
+ * framework-generated id unique per log (one per started event). The
14
+ * provider's `callId` is correlation only and may repeat; two logical calls
15
+ * with identical (name, input) are two executions.
16
+ *
17
+ * Status derivation:
18
+ * started, no terminal event yet → "uncertain" (interrupted: human)
19
+ * succeeded → "succeeded" (confirmed, never re-run)
20
+ * failed (any) → "failed" (a complete receipt IS
21
+ * the outcome — ruling #12 / ADR-0038;
22
+ * safeToRetry stays on the event for
23
+ * history, it no longer feeds status)
24
+ * resolved "rerun" → "rerun" (human cleared it)
25
+ * resolved "abandoned" → "abandoned" (human killed it)
26
+ */
27
+ import type { Event } from "@vincemakes/kiso-core";
28
+ export type ExecutionStatus = "uncertain" | "succeeded" | "failed" | "rerun" | "abandoned";
29
+ export interface ExecutionRecord {
30
+ readonly executionId: string;
31
+ readonly callId: string;
32
+ readonly name: string;
33
+ readonly input: Readonly<Record<string, unknown>>;
34
+ readonly status: ExecutionStatus;
35
+ /** Present when `status` is "succeeded" — the durable result to replay. */
36
+ readonly result?: {
37
+ readonly content: string;
38
+ readonly isError: false;
39
+ };
40
+ readonly error?: string;
41
+ }
42
+ /** executionId → durable status, rebuilt purely from events (ADR-0002). */
43
+ export declare function executionLedger(events: readonly Event[]): Map<string, ExecutionRecord>;
44
+ /** The LATEST execution record for a provider call id (correlation only). */
45
+ export declare function executionForCallId(events: readonly Event[], callId: string): ExecutionRecord | undefined;
package/dist/ledger.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * L2 — the execution ledger: exactly-once side effects from the event log.
3
+ *
4
+ * Every tool execution writes `tool_execution_started` before the handler
5
+ * and `tool_execution_succeeded` / `tool_execution_failed` after
6
+ * (kernel/loop.ts). From those events alone — no second store — this module
7
+ * answers the recovery questions:
8
+ *
9
+ * 1. What is the durable status of execution X? (`executionLedger`)
10
+ * 2. What is the latest execution of call Y? (`executionForCallId`)
11
+ *
12
+ * IDENTITY (Area 3): the ledger is keyed by `executionId` — a persistent,
13
+ * framework-generated id unique per log (one per started event). The
14
+ * provider's `callId` is correlation only and may repeat; two logical calls
15
+ * with identical (name, input) are two executions.
16
+ *
17
+ * Status derivation:
18
+ * started, no terminal event yet → "uncertain" (interrupted: human)
19
+ * succeeded → "succeeded" (confirmed, never re-run)
20
+ * failed (any) → "failed" (a complete receipt IS
21
+ * the outcome — ruling #12 / ADR-0038;
22
+ * safeToRetry stays on the event for
23
+ * history, it no longer feeds status)
24
+ * resolved "rerun" → "rerun" (human cleared it)
25
+ * resolved "abandoned" → "abandoned" (human killed it)
26
+ */
27
+ /** executionId → durable status, rebuilt purely from events (ADR-0002). */
28
+ export function executionLedger(events) {
29
+ const ledger = new Map();
30
+ for (const ev of events) {
31
+ switch (ev.type) {
32
+ case "tool_execution_started":
33
+ ledger.set(ev.executionId, {
34
+ executionId: ev.executionId,
35
+ callId: ev.callId,
36
+ name: ev.name,
37
+ input: ev.input,
38
+ status: "uncertain",
39
+ });
40
+ break;
41
+ case "tool_execution_succeeded": {
42
+ const prior = ledger.get(ev.executionId);
43
+ if (prior) {
44
+ ledger.set(ev.executionId, { ...prior, status: "succeeded", result: ev.result });
45
+ }
46
+ break;
47
+ }
48
+ case "tool_execution_failed": {
49
+ const prior = ledger.get(ev.executionId);
50
+ if (prior) {
51
+ ledger.set(ev.executionId, {
52
+ ...prior,
53
+ // ruling #12 (ADR-0038): a complete receipt IS the outcome —
54
+ // failed is "failed", never "uncertain"; uncertainty
55
+ // belongs to the crash window alone (started, no receipt).
56
+ status: "failed",
57
+ ...(ev.error !== undefined ? { error: ev.error } : {}),
58
+ });
59
+ }
60
+ break;
61
+ }
62
+ case "tool_execution_resolved": {
63
+ const prior = ledger.get(ev.executionId);
64
+ if (prior) {
65
+ ledger.set(ev.executionId, {
66
+ ...prior,
67
+ status: ev.resolution === "rerun" ? "rerun" : "abandoned",
68
+ });
69
+ }
70
+ break;
71
+ }
72
+ default:
73
+ break;
74
+ }
75
+ }
76
+ return ledger;
77
+ }
78
+ /** The LATEST execution record for a provider call id (correlation only). */
79
+ export function executionForCallId(events, callId) {
80
+ const ledger = executionLedger(events);
81
+ let found;
82
+ for (const ev of events) {
83
+ if (ev.type !== "tool_execution_started")
84
+ continue;
85
+ if (ev.callId !== callId)
86
+ continue;
87
+ found = ledger.get(ev.executionId);
88
+ }
89
+ return found;
90
+ }
package/dist/run.js CHANGED
@@ -176,7 +176,7 @@ export class Run {
176
176
  if (ev.seq > baseSeq)
177
177
  await this.#session.persist(this.runId, ev);
178
178
  };
179
- for await (const ev of this.#recover(log, signal, lastOpen.events)) {
179
+ for await (const ev of this.#recover(log, signal, lastOpen.events, approvalChain, this.#config.hooks)) {
180
180
  await persist(ev);
181
181
  yield ev;
182
182
  }
@@ -242,10 +242,185 @@ export class Run {
242
242
  * re-approved); a denial writes its tool result; a succeeded/failed
243
243
  * execution whose tool_result never landed is completed from the
244
244
  * receipt. Undecided requests pause and await approve().
245
+ *
246
+ * R-E 0.1.43 (Gap A): a committed turn's tool_call_end with no durable
247
+ * decision and no execution is UNDECIDED — recovery re-enters it into
248
+ * the approval pipeline before anything else (only a durable
249
+ * permission_decided authorizes an effect).
245
250
  */
246
- async *#recover(log, signal, scope) {
247
- const requests = scope.filter((e) => e.type === "permission_requested");
251
+ async *#recover(log, signal, scope, approvalChain, hooks) {
252
+ // ── Gap B: the tail draft is abandoned — never committed history ──
253
+ // "A model output suffix without a committed stop is an incomplete
254
+ // draft and must never become committed provider history." The
255
+ // resume appends the abandon marker FIRST: it voids the range after
256
+ // the last committed boundary (stop / user_input / terminal /
257
+ // compaction / summarized — or an earlier marker), so the projection
258
+ // excludes the draft and a call inside it is never executed (the
259
+ // boundary clause: the two Gaps divide at the stop). The audit bytes
260
+ // stay; a marker is kernel-exclusive (the AdapterEvent whitelist).
261
+ // Idempotent: an already-voided draft has a marker as its last
262
+ // boundary — the detection finds no output after it, and the
263
+ // recovered events (decided/started/result) are no draft.
264
+ const boundary = [...scope].reverse().find((e) => e.type === "stop" ||
265
+ e.type === "user_input" ||
266
+ e.type === "terminal" ||
267
+ e.type === "microcompacted" ||
268
+ e.type === "compacted" ||
269
+ e.type === "summarized" ||
270
+ e.type === "model_output_abandoned");
271
+ if (boundary !== undefined) {
272
+ const draft = scope.some((e) => (e.type === "text_delta" || e.type === "thinking") && e.seq > boundary.seq);
273
+ if (draft) {
274
+ yield log.append({
275
+ type: "model_output_abandoned",
276
+ voidFromSeq: boundary.seq,
277
+ reason: "a model output suffix without a committed stop — abandoned on resume",
278
+ });
279
+ }
280
+ }
281
+ // The invocation's framework identity (R-E 0.1.43): the
282
+ // tool_call_end's seq — carried by new logs, derived by
283
+ // callId+proximity for old ones (the last such call before the seq).
284
+ const callSeqOf = (callId, before) => {
285
+ let seq;
286
+ for (const e of scope) {
287
+ if (e.type === "tool_call_end" && e.callId === callId && e.seq < before)
288
+ seq = e.seq;
289
+ }
290
+ return seq;
291
+ };
292
+ // ── Gap A: a committed turn's UNDECIDED invocation ────────────────
293
+ // A durable stop with a bare tool_call_end (no decision, no
294
+ // execution) re-enters the approval pipeline: the composed chain
295
+ // decides — allow → durable permission_decided (decidedBy
296
+ // faithfully) + the persisted execution; deny → decided + the
297
+ // denial result; ask/all-abstain → permission_requested (the
298
+ // requests pass below announces it and waits for the human). No
299
+ // guessing, no inheriting, no retro-authorization — re-decide.
300
+ // A durable POLICY verdict (E1: decidedBy set) newer than the call
301
+ // binds it — the chain never re-runs for a decided invocation.
302
+ const gapAsks = [];
303
+ for (const call of scope) {
304
+ if (call.type !== "tool_call_end")
305
+ continue;
306
+ // The boundary clause (the directive): a call whose turn has no
307
+ // legal stop is a DRAFT's call — Gap B voids it (never executed,
308
+ // never in the provider projection); this stage never touches it.
309
+ // The two Gaps divide at the stop; no mixing.
310
+ const turnEnd = scope.find((e) => e.type === "user_input" && e.seq > call.seq)?.seq ?? Number.POSITIVE_INFINITY;
311
+ const turnStop = scope.some((e) => e.type === "stop" && e.seq > call.seq && e.seq < turnEnd);
312
+ if (!turnStop)
313
+ continue;
314
+ // The requests pass below owns request-tracked invocations (it
315
+ // binds the stored request by decisionId, or pauses for the
316
+ // human) — Gap A must never re-decide over a stored
317
+ // permission_requested. "Only a durable permission_decided
318
+ // authorizes an effect": a pending request is not a decision.
319
+ const hasRequest = log.all.some((e) => e.type === "permission_requested" && e.callId === call.callId && e.seq > call.seq);
320
+ if (hasRequest)
321
+ continue;
322
+ const decided = log.all.find((e) => e.type === "permission_decided" && e.callId === call.callId && e.seq > call.seq && e.decidedBy !== undefined);
323
+ const hasExecution = log.all.some((e) => e.type === "tool_execution_started" && e.callId === call.callId && e.seq > call.seq);
324
+ const hasResult = log.all.some((e) => e.type === "tool_result" && e.callId === call.callId && e.seq > call.seq);
325
+ if (hasResult)
326
+ continue; // closed — nothing to fill
327
+ if (decided !== undefined) {
328
+ // E1: the durable verdict speaks for the call — apply it
329
+ // without re-running the chain.
330
+ if (signal.aborted)
331
+ return;
332
+ if (decided.decision === "approved" && !hasExecution) {
333
+ yield* this.#executePersisted(call.callId, call.name, call.input ?? {}, call.seq, signal);
334
+ }
335
+ else if (!hasResult) {
336
+ yield* this.#denialResult(call.callId, decided.reason ?? "denied by user", call.seq);
337
+ }
338
+ continue;
339
+ }
340
+ // UNDECIDED — re-enter the approval pipeline with the LIVE
341
+ // decision order (loop.ts decideCall): the composed chain
342
+ // first; only when no chain exists do the hooks' onPreTool
343
+ // speak (defer → ask, deny → deny, allow → allow); no policies
344
+ // at all → the kernel's default allow. Same semantics as the
345
+ // live path — a defer policy must not collapse into an
346
+ // auto-allow on resume. A throwing chain counts as ask: it
347
+ // speaks, never silently (the live parity).
348
+ const payload = { callId: call.callId, name: call.name, input: call.input ?? {} };
349
+ // The chain's PolicyCall carries name+input only — callId is the
350
+ // framework's, the hook's is the provider-facing ToolCallPayload.
351
+ const policyCall = { name: payload.name, input: payload.input };
352
+ let verdict;
353
+ try {
354
+ if (approvalChain !== undefined) {
355
+ const chainVerdict = await abortable(Promise.resolve(approvalChain.decide(policyCall, { signal, sessionId: this.#session.id })), signal);
356
+ if (chainVerdict === ABORTED)
357
+ return;
358
+ verdict = chainVerdict;
359
+ }
360
+ }
361
+ catch {
362
+ verdict = { action: "ask" };
363
+ }
364
+ if (verdict === undefined && hooks?.onPreTool !== undefined) {
365
+ const decision = await abortable(Promise.resolve(hooks.onPreTool(payload, { sessionId: this.#session.id })), signal);
366
+ if (decision === ABORTED)
367
+ return;
368
+ if (decision.action === "defer")
369
+ verdict = { action: "ask" };
370
+ else if (decision.action !== "allow")
371
+ verdict = { action: "deny", reason: decision.reason ?? "denied" };
372
+ else
373
+ verdict = { action: "allow" };
374
+ }
375
+ if (verdict === undefined)
376
+ verdict = { action: "allow" }; // no policies — the kernel's default allow
377
+ const decisionId = `d-${log.all.length + 1}`;
378
+ if (verdict.action === "allow") {
379
+ yield log.append({
380
+ type: "permission_decided",
381
+ decisionId,
382
+ callId: call.callId,
383
+ invocationSeq: call.seq,
384
+ decision: "approved",
385
+ ...("decidedBy" in verdict ? { decidedBy: verdict.decidedBy } : {}),
386
+ });
387
+ if (signal.aborted)
388
+ return;
389
+ yield* this.#executePersisted(call.callId, call.name, call.input ?? {}, call.seq, signal);
390
+ }
391
+ else if (verdict.action === "deny") {
392
+ yield log.append({
393
+ type: "permission_decided",
394
+ decisionId,
395
+ callId: call.callId,
396
+ invocationSeq: call.seq,
397
+ decision: "denied",
398
+ ...("reason" in verdict && verdict.reason !== undefined ? { reason: verdict.reason } : {}),
399
+ ...("decidedBy" in verdict ? { decidedBy: verdict.decidedBy } : {}),
400
+ });
401
+ yield* this.#denialResult(call.callId, ("reason" in verdict && verdict.reason) || "denied", call.seq);
402
+ }
403
+ else {
404
+ // ask / all-abstain — the requests pass below announces the
405
+ // stored request and waits for the human.
406
+ const appended = log.append({
407
+ type: "permission_requested",
408
+ decisionId,
409
+ callId: call.callId,
410
+ invocationSeq: call.seq,
411
+ name: call.name,
412
+ input: call.input ?? {},
413
+ });
414
+ gapAsks.push(appended);
415
+ yield appended;
416
+ }
417
+ }
418
+ const requests = [...scope.filter((e) => e.type === "permission_requested"), ...gapAsks];
248
419
  for (const pending of requests) {
420
+ // R-E 0.1.43: the writes below carry the invocation's framework
421
+ // identity — the request's own, or the callId+proximity
422
+ // fallback for old logs (the compat contract).
423
+ const invocationSeq = pending.invocationSeq ?? callSeqOf(pending.callId, pending.seq);
249
424
  const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === pending.decisionId);
250
425
  // round 4: paired by events NEWER than the request — a historical
251
426
  // same-callId execution from an earlier run must not count as THIS
@@ -303,10 +478,10 @@ export class Run {
303
478
  });
304
479
  if (final.action === "allow") {
305
480
  if (!hasExecution)
306
- yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
481
+ yield* this.#executePersisted(pending.callId, pending.name, pending.input, invocationSeq, signal);
307
482
  }
308
483
  else if (!hasResult) {
309
- yield* this.#denialResult(pending.callId, final.reason ?? "denied by user");
484
+ yield* this.#denialResult(pending.callId, final.reason ?? "denied by user", invocationSeq);
310
485
  }
311
486
  }
312
487
  else if (decided.decision === "approved") {
@@ -316,10 +491,10 @@ export class Run {
316
491
  if (signal.aborted)
317
492
  return;
318
493
  if (!hasExecution)
319
- yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
494
+ yield* this.#executePersisted(pending.callId, pending.name, pending.input, invocationSeq, signal);
320
495
  }
321
496
  else if (!hasResult) {
322
- yield* this.#denialResult(pending.callId, decided.reason ?? "denied by user");
497
+ yield* this.#denialResult(pending.callId, decided.reason ?? "denied by user", invocationSeq);
323
498
  }
324
499
  }
325
500
  // Receipt repair: an execution that reached a terminal state but
@@ -385,7 +560,7 @@ export class Run {
385
560
  * the permission hook (it was decided) and the model (it was never
386
561
  * asked to re-issue). Full ledgered lifecycle.
387
562
  */
388
- async *#executePersisted(callId, name, input, signal) {
563
+ async *#executePersisted(callId, name, input, invocationSeq, signal) {
389
564
  const log = this.#session.log;
390
565
  const tool = this.#config.registry.get(name);
391
566
  const executionId = `ex-${log.lastSeq + 1}`;
@@ -393,7 +568,14 @@ export class Run {
393
568
  // not start the side effect (finding 3).
394
569
  if (signal.aborted)
395
570
  return;
396
- yield log.append({ type: "tool_execution_started", executionId, callId, name, input });
571
+ yield log.append({
572
+ type: "tool_execution_started",
573
+ executionId,
574
+ callId,
575
+ ...(invocationSeq !== undefined ? { invocationSeq } : {}),
576
+ name,
577
+ input,
578
+ });
397
579
  let result;
398
580
  if (tool === undefined) {
399
581
  result = { content: `Unknown tool: ${name}`, isError: true, errorKind: "invalid_input" };
@@ -432,6 +614,7 @@ export class Run {
432
614
  type: "tool_execution_failed",
433
615
  executionId,
434
616
  callId,
617
+ ...(invocationSeq !== undefined ? { invocationSeq } : {}),
435
618
  error: result.content,
436
619
  // P1-9: errorKind only exists on errors — runtime-guarded too.
437
620
  ...(result.isError && result.errorKind !== undefined ? { errorKind: result.errorKind } : {}),
@@ -444,6 +627,7 @@ export class Run {
444
627
  type: "tool_execution_succeeded",
445
628
  executionId,
446
629
  callId,
630
+ ...(invocationSeq !== undefined ? { invocationSeq } : {}),
447
631
  result: { content: result.content, isError: false },
448
632
  ...(result.tags !== undefined ? { tags: result.tags } : {}),
449
633
  });
@@ -451,6 +635,7 @@ export class Run {
451
635
  yield log.append({
452
636
  type: "tool_result",
453
637
  callId,
638
+ ...(invocationSeq !== undefined ? { invocationSeq } : {}),
454
639
  content: result.content,
455
640
  isError: result.isError,
456
641
  // P1-9: errorKind only exists on errors — runtime-guarded too.
@@ -465,11 +650,12 @@ export class Run {
465
650
  // alone. A retry passes the approval chain again.
466
651
  }
467
652
  /** The model-facing result of a durable denial — no execution happened. */
468
- async *#denialResult(callId, reason) {
653
+ async *#denialResult(callId, reason, invocationSeq) {
469
654
  const denial = denialResult(reason);
470
655
  yield this.#session.log.append({
471
656
  type: "tool_result",
472
657
  callId,
658
+ ...(invocationSeq !== undefined ? { invocationSeq } : {}),
473
659
  content: denial.content,
474
660
  isError: true,
475
661
  errorKind: denial.errorKind,
package/dist/session.d.ts CHANGED
@@ -145,7 +145,7 @@ export declare class AgentSession {
145
145
  */
146
146
  approve(decisionId: string, allow: boolean, reason?: string): Promise<void>;
147
147
  /** Executions that started but never reported a result (crash window). */
148
- uncertainExecutions(): import("@vincemakes/kiso-core").ExecutionRecord[];
148
+ uncertainExecutions(): import("./ledger.js").ExecutionRecord[];
149
149
  /**
150
150
  * The human's verdict on an interrupted execution, keyed by EXECUTION ID
151
151
  * (B group): "rerun" (the human says the side effect did NOT happen — the
package/dist/session.js CHANGED
@@ -27,7 +27,8 @@
27
27
  * support in recovery.ts, the E1/E2 composition helpers in compose.ts —
28
28
  * same package, same exports (index.ts re-exports all four).
29
29
  */
30
- import { EventLog, executionLedger, projectMessages, } from "@vincemakes/kiso-core";
30
+ import { EventLog, projectMessages, } from "@vincemakes/kiso-core";
31
+ import { executionLedger } from "./ledger.js";
31
32
  import { denialResult } from "@vincemakes/kiso-core";
32
33
  import { estimateSummarySavings, KEEP_RECENT_ROUNDS, lastSummaryPoint, summarizeConversation, summaryBoundarySeq, } from "./summarize.js";
33
34
  import { estimateTokens } from "@vincemakes/kiso-core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,11 +21,11 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.1.33"
24
+ "@vincemakes/kiso-core": "0.1.34"
25
25
  },
26
26
  "peerDependencies": {
27
- "@vincemakes/kiso-provider-anthropic": "0.1.34",
28
- "@vincemakes/kiso-provider-openai": "0.1.34"
27
+ "@vincemakes/kiso-provider-anthropic": "0.1.35",
28
+ "@vincemakes/kiso-provider-openai": "0.1.35"
29
29
  },
30
30
  "peerDependenciesMeta": {
31
31
  "@vincemakes/kiso-provider-anthropic": {
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "devDependencies": {
39
- "@vincemakes/kiso-evals": "0.1.33",
39
+ "@vincemakes/kiso-evals": "0.1.35",
40
40
  "@types/node": "^26.1.2",
41
41
  "typescript": "^5.7.2",
42
42
  "vitest": "^3.0.0"