@vincemakes/kiso-runtime 0.11.0 → 0.13.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/compose.js CHANGED
@@ -89,8 +89,8 @@ export function composeHooks(existing, extensions) {
89
89
  // message as the PREVIOUS one left it (the existing come first), and a null (veto)
90
90
  // anywhere ends the chain immediately: never "no opinion" for the
91
91
  // next handler to outvote. Adding an extension can therefore never
92
- // make the chain MORE permissive (the approval chain's deny>ask>allow
93
- // monotonicity, on the message side).
92
+ // make the chain MORE permissive (the approval chain's
93
+ // deny > allow > ask monotonicity, on the message side).
94
94
  out.onUserMessage = async (msg, ctx) => {
95
95
  let current = msg;
96
96
  for (const h of messageHandlers) {
package/dist/index.d.ts CHANGED
@@ -12,12 +12,16 @@
12
12
  * (unstable, first-party only — see internal.ts).
13
13
  *
14
14
  * Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
15
- * names are deprecated aliases, removed in the next major (additive,
16
- * ADR-0051 Amendment 1release rounds move the cli minor).
15
+ * names are deprecated aliases, removed in the next major (additive). The
16
+ * cadence is ADR-0051 Amendment 4the LOCKSTEP line: all thirteen public
17
+ * packages share one version, a release round is a minor on the line and a
18
+ * fix-only round a patch. (Amendment 1's per-package counters, which moved
19
+ * the cli minor on its own, are superseded.)
17
20
  */
18
21
  export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
19
22
  export type { AgentDefinition, PermissionPolicy, PermissionRule } from "./agent.js";
20
23
  export { AgentSession, AgentSession as Session } from "./session.js";
24
+ export type { SideQueryOptions } from "./session.js";
21
25
  export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
22
26
  export type { ApprovalRequest, CompactInfo, ContextPolicy, SessionConfig, SummarizeResult } from "./session.js";
23
27
  export { Run } from "./run.js";
package/dist/index.js CHANGED
@@ -12,8 +12,11 @@
12
12
  * (unstable, first-party only — see internal.ts).
13
13
  *
14
14
  * Canonical names: Agent = AgentRuntime, Session = AgentSession. The old
15
- * names are deprecated aliases, removed in the next major (additive,
16
- * ADR-0051 Amendment 1release rounds move the cli minor).
15
+ * names are deprecated aliases, removed in the next major (additive). The
16
+ * cadence is ADR-0051 Amendment 4the LOCKSTEP line: all thirteen public
17
+ * packages share one version, a release round is a minor on the line and a
18
+ * fix-only round a patch. (Amendment 1's per-package counters, which moved
19
+ * the cli minor on its own, are superseded.)
17
20
  */
18
21
  // agent
19
22
  export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
package/dist/ledger.d.ts CHANGED
@@ -14,8 +14,17 @@
14
14
  * provider's `callId` is correlation only and may repeat; two logical calls
15
15
  * with identical (name, input) are two executions.
16
16
  *
17
+ * UNCERTAINTY BELONGS TO THE CRASH WINDOW ALONE (ADR-0038): the ONLY shape
18
+ * that derives "uncertain" here is a `tool_execution_started` with no
19
+ * receipt after it — the process died between the write-ahead and the
20
+ * outcome, so what the side effect did is genuinely unknown and a human
21
+ * must rule. A receipted execution is an outcome, success or failure alike;
22
+ * no tool property and no error kind can make a receipted execution
23
+ * uncertain. ADR-0038 superseded ADR-0025 decision #3, which had routed a
24
+ * failed non-idempotent execution into this bucket.
25
+ *
17
26
  * Status derivation:
18
- * started, no terminal event yet → "uncertain" (interrupted: human)
27
+ * started, no terminal event yet → "uncertain" (the crash window: human)
19
28
  * succeeded → "succeeded" (confirmed, never re-run)
20
29
  * failed (any) → "failed" (a complete receipt IS
21
30
  * the outcome — ruling #12 / ADR-0038;
package/dist/ledger.js CHANGED
@@ -14,8 +14,17 @@
14
14
  * provider's `callId` is correlation only and may repeat; two logical calls
15
15
  * with identical (name, input) are two executions.
16
16
  *
17
+ * UNCERTAINTY BELONGS TO THE CRASH WINDOW ALONE (ADR-0038): the ONLY shape
18
+ * that derives "uncertain" here is a `tool_execution_started` with no
19
+ * receipt after it — the process died between the write-ahead and the
20
+ * outcome, so what the side effect did is genuinely unknown and a human
21
+ * must rule. A receipted execution is an outcome, success or failure alike;
22
+ * no tool property and no error kind can make a receipted execution
23
+ * uncertain. ADR-0038 superseded ADR-0025 decision #3, which had routed a
24
+ * failed non-idempotent execution into this bucket.
25
+ *
17
26
  * Status derivation:
18
- * started, no terminal event yet → "uncertain" (interrupted: human)
27
+ * started, no terminal event yet → "uncertain" (the crash window: human)
19
28
  * succeeded → "succeeded" (confirmed, never re-run)
20
29
  * failed (any) → "failed" (a complete receipt IS
21
30
  * the outcome — ruling #12 / ADR-0038;
@@ -79,20 +79,45 @@ export function deriveRecoveryPlan(events, scope) {
79
79
  // order blocks; the driver throws with the full list. A receipted
80
80
  // execution is an outcome (ruling #12 / the α ruling: the audit keeps
81
81
  // it) — never uncertain.
82
+ // ADR-0038: uncertainty belongs to the crash window ALONE. This filter
83
+ // is the whole of it — `executionLedger` derives "uncertain" only for
84
+ // started-without-receipt, so a FAILED execution never reaches this
85
+ // branch and never blocks a resume, whatever its safeToRetry says.
82
86
  const uncertain = [...executionLedger(events).values()].filter((r) => r.status === "uncertain");
83
87
  if (uncertain.length > 0)
84
88
  return { kind: "RESOLVE_UNCERTAIN", executionId: uncertain[0].executionId };
85
- // 4. Gap B: a text-bearing no-stop suffix is an abandoned draft — void it.
86
- // Text-only detection (the 0.1.44 verification): a bare tool-call
87
- // suffix is the legal approval-panel pause, never a draft. The
88
- // boundary/draft scans run over the OPEN RUN's events INCLUDING the
89
- // driver's own appends the driver re-derives after every append,
90
- // and the marker IT appended must already be the last boundary (the
91
- // old one-pass Gap B never needed this: it ran before any append).
89
+ // 4. Gap B: a no-stop suffix of MODEL OUTPUT is an abandoned draft — void
90
+ // it. The boundary/draft scans run over the OPEN RUN's events
91
+ // INCLUDING the driver's own appends the driver re-derives after
92
+ // every append, and the marker IT appended must already be the last
93
+ // boundary (the old one-pass Gap B never needed this: it ran before
94
+ // any append).
95
+ //
96
+ // EC-1, finding EC1-F1: the scan counts `tool_call_end` too. It used
97
+ // to be text-only, on the 0.1.44 reasoning that "a bare tool-call
98
+ // suffix is the legal approval-panel pause, never a draft" — but that
99
+ // reasoning only ever held for a suffix carrying a REQUEST (the
100
+ // liveAsk clause below, which still decides that case). A bare call
101
+ // with no request is not a pause; it is an uncommitted draft, and
102
+ // leaving it un-voided projects an assistant `tool_use` block with no
103
+ // result — a provider 400. Pre-EC-1 the window was microseconds wide
104
+ // (the stop was persisted the moment it arrived); ① holds the stop
105
+ // until the stream is exhausted, so crash-pair A lands in this shape
106
+ // by construction.
92
107
  const openEvents = openRunEvents(events, scope);
93
108
  const boundary = [...openEvents].reverse().find(isBoundary);
94
109
  if (boundary !== undefined) {
95
- const afterBoundary = openEvents.some((e) => (e.type === "text_delta" || e.type === "thinking") && e.seq > boundary.seq);
110
+ // A call only makes a DRAFT while it is still pure intent. Once it
111
+ // has a durable `tool_execution_started` the world may have moved,
112
+ // and the existing repair passes own it: voiding such a call would
113
+ // strand a real receipt behind a voided declaration (pair atomicity
114
+ // would then drop its result and the model would never learn the
115
+ // outcome of work that actually happened). Pre-EC-1 logs contain
116
+ // exactly that shape — a call that launched mid-stream and finished
117
+ // before its stop was persisted — and they must keep recovering the
118
+ // way they always did.
119
+ const unexecuted = (e) => e.type === "tool_call_end" && !openEvents.some((x) => x.type === "tool_execution_started" && x.callId === e.callId);
120
+ const afterBoundary = openEvents.some((e) => (e.type === "text_delta" || e.type === "thinking" || unexecuted(e)) && e.seq > boundary.seq);
96
121
  // The approval-panel pause: a suffix that carries a pending ask of the
97
122
  // LIVE turn — the last boundary is the user_input, no stop since — is
98
123
  // the human's pause, never a draft: the call was extracted and asked,
@@ -101,6 +126,16 @@ export function deriveRecoveryPlan(events, scope) {
101
126
  // crash mid-pause leaves exactly this shape.) A request AFTER a STOP
102
127
  // is different: it is the draft's own ask (0143's shape) — the marker
103
128
  // voids it and the request expires with the draft.
129
+ //
130
+ // EC-1 ERA NOTE — this clause is now GENERATION COMPAT, and is kept
131
+ // for that reason alone. A kiso at 0.13.0 or later cannot produce the
132
+ // shape: asks moved AFTER Turn Commit (③), so a durable
133
+ // `permission_requested` always has a durable stop before it. Logs
134
+ // written by EARLIER bins do carry it, they are on disk, and they
135
+ // must keep re-presenting their ask instead of being voided as
136
+ // drafts. Deleting this clause would silently change how pre-EC-1
137
+ // sessions recover; it stays until the generation corpus no longer
138
+ // contains a pre-EC-1 era.
104
139
  const liveAsk = afterBoundary &&
105
140
  boundary.type === "user_input" &&
106
141
  openEvents.some((e) => e.type === "permission_requested" && e.seq > boundary.seq);
package/dist/session.d.ts CHANGED
@@ -30,6 +30,21 @@
30
30
  import { EventLog, type AbortSignalLike, type Adapter, type Event, type KisoExtension, type Message, type PermissionDecision, type Tool } from "@vincemakes/kiso-core";
31
31
  import { type SessionStore } from "./store.js";
32
32
  import { Run } from "./run.js";
33
+ /** TUI2-R3v2 ③ — one off-trajectory model request (session.sideQuery).
34
+ * Deliberately tiny: a purpose to mark it in the trace, its own short
35
+ * system prompt, one user message, and an abort. No tools — a side
36
+ * query answers, it never acts. */
37
+ export interface SideQueryOptions {
38
+ /** what this request is FOR — lands in the trace as `purpose`. */
39
+ readonly purpose: string;
40
+ /** the side query's OWN system prompt; the session's is not sent. */
41
+ readonly systemPrompt: string;
42
+ /** the single user message. */
43
+ readonly prompt: string;
44
+ readonly maxTokens?: number;
45
+ /** cancels the request — the CLI wires esc to it. */
46
+ readonly signal?: AbortSignalLike;
47
+ }
33
48
  /** A session whose disk write was rejected (stale handle) is PERMANENTLY
34
49
  * poisoned: its in-memory log no longer matches the disk, so no further
35
50
  * run may proceed — reload the session (round 1). */
@@ -103,6 +118,43 @@ export declare class AgentSession {
103
118
  * per-run tracer reads the SAME #config.provider; one source, one
104
119
  * route — the CLI and the trace can never disagree. */
105
120
  get provider(): "anthropic" | "openai-compat" | undefined;
121
+ /**
122
+ * TUI2-R3v2 ③ — ONE model request that belongs to no run (the
123
+ * safer-options seam, adjudicated 2026-08-18).
124
+ *
125
+ * A side query exists for the case where the human, staring at a
126
+ * paused approval, presses a button and wants an answer NOW. It is
127
+ * not a turn, not a run, and not part of the trajectory.
128
+ *
129
+ * IT WRITES NOTHING DURABLE. No session-log lines, no receipts, no
130
+ * execution records — nothing. This is the design, not an omission,
131
+ * and it is what makes the method safe to call while a run sits
132
+ * paused: it cannot interleave with that run's own event sequence
133
+ * because it produces no events to interleave. The ONLY durable
134
+ * consequence a side query can have is what the human does with the
135
+ * answer, and that lands through an existing channel — for the
136
+ * safer-options flow, the amend channel, exactly as a typed denial
137
+ * would. Nothing here touches the durable approval contract.
138
+ *
139
+ * IT IS VISIBLE. It rides the same traceGuard every run request
140
+ * rides, so it lands one request line in the trace ledger, carrying
141
+ * a FRESH runId (it is not the paused run's work) and the `purpose`
142
+ * marker (schemaVersion 4) that lets a rent audit separate on-demand
143
+ * requests from a run's own without heuristics. A request the ledger
144
+ * cannot see is rent nobody can audit, and the entire argument for
145
+ * an on-demand feature is that its rent is countable.
146
+ *
147
+ * IT PAYS ITS OWN, SMALLER RENT. It sends its own short system
148
+ * prompt and ONE user message, and NO TOOLS AT ALL — it cannot call
149
+ * anything, it can only answer. Its declared prediction arm is
150
+ * predictSideQueryRentLedger (scripts/request-surface.mjs), gated
151
+ * against a real call in rent-ledger-gate.test.ts.
152
+ *
153
+ * The returned string is the model's text, concatenated. The caller
154
+ * parses it and must treat a failure to parse as a failure — see the
155
+ * CLI's honest degradation.
156
+ */
157
+ sideQuery(options: SideQueryOptions): Promise<string>;
106
158
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
107
159
  run(input: string, options?: {
108
160
  signal?: AbortSignalLike;
package/dist/session.js CHANGED
@@ -38,6 +38,10 @@ import { estimateTokens } from "@vincemakes/kiso-core";
38
38
  import { StaleWriterError } from "./store.js";
39
39
  import { composeHooks } from "./compose.js";
40
40
  import { Run } from "./run.js";
41
+ // TUI2-R3v2 ③ — the side query rides the SAME tracer the runs ride; that
42
+ // sameness is the whole point (one ledger, one shape, no second path).
43
+ import { RequestTracer, traceGuard } from "./trace/guard.js";
44
+ import { runtimeVersion } from "./trace/writer.js";
41
45
  /** A session whose disk write was rejected (stale handle) is PERMANENTLY
42
46
  * poisoned: its in-memory log no longer matches the disk, so no further
43
47
  * run may proceed — reload the session (round 1). */
@@ -168,6 +172,83 @@ export class AgentSession {
168
172
  get provider() {
169
173
  return this.#config.provider;
170
174
  }
175
+ /**
176
+ * TUI2-R3v2 ③ — ONE model request that belongs to no run (the
177
+ * safer-options seam, adjudicated 2026-08-18).
178
+ *
179
+ * A side query exists for the case where the human, staring at a
180
+ * paused approval, presses a button and wants an answer NOW. It is
181
+ * not a turn, not a run, and not part of the trajectory.
182
+ *
183
+ * IT WRITES NOTHING DURABLE. No session-log lines, no receipts, no
184
+ * execution records — nothing. This is the design, not an omission,
185
+ * and it is what makes the method safe to call while a run sits
186
+ * paused: it cannot interleave with that run's own event sequence
187
+ * because it produces no events to interleave. The ONLY durable
188
+ * consequence a side query can have is what the human does with the
189
+ * answer, and that lands through an existing channel — for the
190
+ * safer-options flow, the amend channel, exactly as a typed denial
191
+ * would. Nothing here touches the durable approval contract.
192
+ *
193
+ * IT IS VISIBLE. It rides the same traceGuard every run request
194
+ * rides, so it lands one request line in the trace ledger, carrying
195
+ * a FRESH runId (it is not the paused run's work) and the `purpose`
196
+ * marker (schemaVersion 4) that lets a rent audit separate on-demand
197
+ * requests from a run's own without heuristics. A request the ledger
198
+ * cannot see is rent nobody can audit, and the entire argument for
199
+ * an on-demand feature is that its rent is countable.
200
+ *
201
+ * IT PAYS ITS OWN, SMALLER RENT. It sends its own short system
202
+ * prompt and ONE user message, and NO TOOLS AT ALL — it cannot call
203
+ * anything, it can only answer. Its declared prediction arm is
204
+ * predictSideQueryRentLedger (scripts/request-surface.mjs), gated
205
+ * against a real call in rent-ledger-gate.test.ts.
206
+ *
207
+ * The returned string is the model's text, concatenated. The caller
208
+ * parses it and must treat a failure to parse as a failure — see the
209
+ * CLI's honest degradation.
210
+ */
211
+ async sideQuery(options) {
212
+ this.ensureHealthy();
213
+ // a fresh id: this request is not the paused run's work, and giving
214
+ // it the run's id would put on-demand rent inside a run's total.
215
+ const runId = crypto.randomUUID();
216
+ const tracer = new RequestTracer({
217
+ root: this.#store.root,
218
+ sessionId: this.id,
219
+ runId,
220
+ provider: this.#config.provider ?? "adapter",
221
+ model: this.#config.model,
222
+ adapterVersion: runtimeVersion(),
223
+ purpose: options.purpose,
224
+ // the manifest's seqRange pointers derive from the log, and a side
225
+ // query's messages come from NO events — an empty log is the
226
+ // honest input, and it keeps the manifest from pointing at events
227
+ // this request never sent.
228
+ log: [],
229
+ // the declared rent arm: its own prompt, no appends, no tools
230
+ rentParts: { base: options.systemPrompt, appends: [] },
231
+ });
232
+ tracer.init();
233
+ const guarded = traceGuard(tracer, this.#adapter);
234
+ let text = "";
235
+ try {
236
+ for await (const ev of guarded.stream({
237
+ model: this.#config.model,
238
+ messages: [{ role: "user", content: options.prompt }],
239
+ systemPrompt: options.systemPrompt,
240
+ ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
241
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
242
+ })) {
243
+ if (ev.type === "text_delta")
244
+ text += ev.text;
245
+ }
246
+ }
247
+ finally {
248
+ tracer.finishRun();
249
+ }
250
+ return text;
251
+ }
171
252
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
172
253
  run(input, options) {
173
254
  this.ensureHealthy();
@@ -34,6 +34,12 @@ export interface RequestTracerDeps {
34
34
  adapterVersion?: string | null;
35
35
  /** The session log — the manifest's seqRange pointers derive from it. */
36
36
  log: readonly Event[];
37
+ /** TUI2-R3v2 ③ — what these requests are FOR, when they are not a
38
+ * run's own work. Omitted by every run (the tracer Run builds); set to
39
+ * "safer-options" by session.sideQuery. It lands verbatim on each
40
+ * record this tracer settles, which is what lets a rent audit tell an
41
+ * on-demand request from an ambient one without guessing. */
42
+ purpose?: string;
37
43
  /** E3 — the rent ledger's inputs: the base prompt as configured and
38
44
  * the per-extension appends in load order (the adapter's composed
39
45
  * systemPrompt is their RESULT — the parts are what the ledger
@@ -36,6 +36,7 @@ export class RequestTracer {
36
36
  #runId;
37
37
  #adapterVersion;
38
38
  #rentParts;
39
+ #purpose;
39
40
  #requestIndex = 0;
40
41
  #contextHashCounts = new Map();
41
42
  constructor(deps) {
@@ -45,6 +46,7 @@ export class RequestTracer {
45
46
  this.#runId = deps.runId;
46
47
  this.#adapterVersion = deps.adapterVersion ?? null;
47
48
  this.#rentParts = deps.rentParts;
49
+ this.#purpose = deps.purpose;
48
50
  }
49
51
  init() {
50
52
  this.#writer.init();
@@ -139,6 +141,10 @@ export class RequestTracer {
139
141
  kind: "request",
140
142
  requestId: randomUUID(),
141
143
  runId: this.#runId,
144
+ // exactOptionalPropertyTypes: the key is ABSENT on a run request,
145
+ // never `purpose: undefined` — a present-but-undefined key would
146
+ // fail the closed-set gate and would read as a claim in the JSON.
147
+ ...(this.#purpose !== undefined ? { purpose: this.#purpose } : {}),
142
148
  requestIndex: this.#requestIndex++,
143
149
  retryAttempt,
144
150
  provider: this.#provider,
@@ -25,12 +25,22 @@
25
25
  * writers record it; v1/v2 sidecars keep reading as defaults (R1d-1,
26
26
  * R2-1): no rent block = no rent lines = the zero-rent reading, never a
27
27
  * crash.
28
+ *
29
+ * TUI2-R3v2 (0.12.0) — schemaVersion 4: the record gains the OPTIONAL
30
+ * `purpose` marker (the safer-options seam, adjudicated 2026-08-18). A
31
+ * session can now make a request that belongs to no run — a side query
32
+ * (session.sideQuery) — and a ledger that could not tell one from a run
33
+ * request would make every rent audit guesswork. `purpose` is absent on
34
+ * run requests and present on side queries, naming what the request was
35
+ * FOR ("safer-options"). Optional by construction: adding a required
36
+ * field would have invalidated every v3 record ever written, and the
37
+ * whole generation-compat discipline exists to prevent exactly that.
28
38
  */
29
- /** schemaVersion: 3 for 0.2.1 (the rent block). Version 1 = the 1.2.0
30
- * shape, version 2 = the 1.3.0 shape; both kept for generation-compat
31
- * reads (R1d-1, R2-1). Algorithm and shape changes bump it (ADR-0051 §6
32
- * OUT-side versioning). */
33
- export declare const TRACE_SCHEMA_VERSION = 3;
39
+ /** schemaVersion: 4 for 0.12.0 (the optional `purpose` marker). Version
40
+ * 1 = the 1.2.0 shape, 2 = the 1.3.0 shape, 3 = the 0.2.1 shape; all
41
+ * kept for generation-compat reads (R1d-1, R2-1). Algorithm and shape
42
+ * changes bump it (ADR-0051 §6 OUT-side versioning). */
43
+ export declare const TRACE_SCHEMA_VERSION = 4;
34
44
  /** The versions a reader may meet in a ledger. v1 and v2 records are
35
45
  * accepted (generation-compat) and read as defaults — no canonical
36
46
  * block (v1), no rent block (v1, v2). */
@@ -52,7 +62,7 @@ export interface TraceSegment {
52
62
  }
53
63
  /** That is the complete set for 1.2.0. */
54
64
  export interface TraceRecord {
55
- schemaVersion: 3;
65
+ schemaVersion: 4;
56
66
  kind: "request";
57
67
  requestId: string;
58
68
  runId: string;
@@ -101,25 +111,31 @@ export interface TraceRecord {
101
111
  parentInvocationSeq: number;
102
112
  role: string;
103
113
  };
114
+ /** TUI2-R3v2 ③ (v4) — what this request was FOR, when it was not a
115
+ * run's own work. ABSENT on every run request (the overwhelming
116
+ * majority) and present on a side query (session.sideQuery), naming
117
+ * its purpose: "safer-options". A consumer separating on-demand rent
118
+ * from run rent reads this field and needs no heuristic. */
119
+ purpose?: string;
104
120
  ts: number;
105
121
  }
106
122
  /** That is the complete set for 1.2.0. */
107
123
  export interface HeaderLine {
108
- schemaVersion: 3;
124
+ schemaVersion: 4;
109
125
  kind: "header";
110
126
  sessionId: string;
111
127
  kisoVersion: string;
112
128
  createdAt: number;
113
129
  }
114
130
  export interface RunEndLine {
115
- schemaVersion: 3;
131
+ schemaVersion: 4;
116
132
  kind: "run_end";
117
133
  runId: string;
118
134
  ts: number;
119
135
  lastRequestIndex: number;
120
136
  }
121
137
  export interface CrashLine {
122
- schemaVersion: 3;
138
+ schemaVersion: 4;
123
139
  kind: "crash";
124
140
  ts: number;
125
141
  note: string;
@@ -138,7 +154,14 @@ export declare const TRACE_RECORD_FIELDS_V1: readonly ["schemaVersion", "kind",
138
154
  /** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
139
155
  export declare const TRACE_RECORD_FIELDS_V2: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical"];
140
156
  /** The 0.2.1 field set (schemaVersion 3) = the v2 set + `rent`. */
141
- export declare const TRACE_RECORD_FIELDS: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent"];
157
+ export declare const TRACE_RECORD_FIELDS_V3: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent"];
158
+ /** The 0.12.0 field set (schemaVersion 4) = the v3 set + `purpose`.
159
+ * TUI2-R3v2 ③: `purpose` is OPTIONAL — a run request omits it entirely,
160
+ * a side query names what it was for. It is listed in TRACE_RECORD_OPTIONAL
161
+ * below so the closed-set gate accepts a record without it. */
162
+ export declare const TRACE_RECORD_FIELDS: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent", "purpose"];
163
+ /** The fields the closed-set check does not require to be present. */
164
+ export declare const TRACE_RECORD_OPTIONAL: readonly ["purpose"];
142
165
  export declare const TRACE_SEGMENT_FIELDS: readonly ["role", "seqRange", "estTokens", "freshness"];
143
166
  export declare function validateTraceSegment(v: unknown): v is TraceSegment;
144
167
  export declare function validateTraceRecord(v: unknown): v is TraceRecord;
@@ -25,22 +25,33 @@
25
25
  * writers record it; v1/v2 sidecars keep reading as defaults (R1d-1,
26
26
  * R2-1): no rent block = no rent lines = the zero-rent reading, never a
27
27
  * crash.
28
+ *
29
+ * TUI2-R3v2 (0.12.0) — schemaVersion 4: the record gains the OPTIONAL
30
+ * `purpose` marker (the safer-options seam, adjudicated 2026-08-18). A
31
+ * session can now make a request that belongs to no run — a side query
32
+ * (session.sideQuery) — and a ledger that could not tell one from a run
33
+ * request would make every rent audit guesswork. `purpose` is absent on
34
+ * run requests and present on side queries, naming what the request was
35
+ * FOR ("safer-options"). Optional by construction: adding a required
36
+ * field would have invalidated every v3 record ever written, and the
37
+ * whole generation-compat discipline exists to prevent exactly that.
28
38
  */
29
- /** schemaVersion: 3 for 0.2.1 (the rent block). Version 1 = the 1.2.0
30
- * shape, version 2 = the 1.3.0 shape; both kept for generation-compat
31
- * reads (R1d-1, R2-1). Algorithm and shape changes bump it (ADR-0051 §6
32
- * OUT-side versioning). */
33
- export const TRACE_SCHEMA_VERSION = 3;
39
+ /** schemaVersion: 4 for 0.12.0 (the optional `purpose` marker). Version
40
+ * 1 = the 1.2.0 shape, 2 = the 1.3.0 shape, 3 = the 0.2.1 shape; all
41
+ * kept for generation-compat reads (R1d-1, R2-1). Algorithm and shape
42
+ * changes bump it (ADR-0051 §6 OUT-side versioning). */
43
+ export const TRACE_SCHEMA_VERSION = 4;
34
44
  /** The versions a reader may meet in a ledger. v1 and v2 records are
35
45
  * accepted (generation-compat) and read as defaults — no canonical
36
46
  * block (v1), no rent block (v1, v2). */
37
- export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, TRACE_SCHEMA_VERSION]);
47
+ export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, 3, TRACE_SCHEMA_VERSION]);
38
48
  import { PRICING_TABLE_V1, priceFor, pricingTableFor, validateCanonicalUsage } from "../usage/canonical.js";
39
49
  import { validateRentLine } from "./rent.js";
40
50
  export const HASH_SPEC_BY_VERSION = {
41
51
  1: { algorithm: "sha-256", output: "full-hex" },
42
52
  2: { algorithm: "sha-256", output: "full-hex" }, // E2 — the algorithms do not change
43
53
  3: { algorithm: "sha-256", output: "full-hex" }, // E3 — same algorithms, re-pinned (the E2 ritual)
54
+ 4: { algorithm: "sha-256", output: "full-hex" }, // TUI2-R3v2 — `purpose` is a marker, not an input to any hash; re-pinned by the same ritual
44
55
  };
45
56
  export function hashSpecFor(version) {
46
57
  const spec = HASH_SPEC_BY_VERSION[version];
@@ -84,7 +95,14 @@ export const TRACE_RECORD_FIELDS_V1 = [
84
95
  /** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
85
96
  export const TRACE_RECORD_FIELDS_V2 = [...TRACE_RECORD_FIELDS_V1, "canonical"];
86
97
  /** The 0.2.1 field set (schemaVersion 3) = the v2 set + `rent`. */
87
- export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V2, "rent"];
98
+ export const TRACE_RECORD_FIELDS_V3 = [...TRACE_RECORD_FIELDS_V2, "rent"];
99
+ /** The 0.12.0 field set (schemaVersion 4) = the v3 set + `purpose`.
100
+ * TUI2-R3v2 ③: `purpose` is OPTIONAL — a run request omits it entirely,
101
+ * a side query names what it was for. It is listed in TRACE_RECORD_OPTIONAL
102
+ * below so the closed-set gate accepts a record without it. */
103
+ export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V3, "purpose"];
104
+ /** The fields the closed-set check does not require to be present. */
105
+ export const TRACE_RECORD_OPTIONAL = ["purpose"];
88
106
  export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
89
107
  // ── Validators ────────────────────────────────────────────────────────────
90
108
  // Strict by design: extra keys are rejected (the closed set), so a
@@ -135,10 +153,16 @@ export function validateTraceRecord(v) {
135
153
  // block, a v2 sidecar has no rent block — both read as defaults,
136
154
  // accepted, never a crash; the current version is fully checked
137
155
  // (shape + the canonical block + the rent ledger).
138
- if (version !== 1 && version !== 2 && version !== TRACE_SCHEMA_VERSION)
156
+ // TUI2-R3v2 ③: v3 joins the generation-compat set a v3 sidecar has
157
+ // no `purpose` on any record, which reads as "every request was a run
158
+ // request", the true statement about a ledger written before side
159
+ // queries existed.
160
+ if (version !== 1 && version !== 2 && version !== 3 && version !== TRACE_SCHEMA_VERSION)
161
+ return false;
162
+ const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : version === 2 ? TRACE_RECORD_FIELDS_V2 : version === 3 ? TRACE_RECORD_FIELDS_V3 : TRACE_RECORD_FIELDS;
163
+ if (!hasClosedKeys(v, fields, ["lineageLink", ...TRACE_RECORD_OPTIONAL]))
139
164
  return false;
140
- const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : version === 2 ? TRACE_RECORD_FIELDS_V2 : TRACE_RECORD_FIELDS;
141
- if (!hasClosedKeys(v, fields, ["lineageLink"]))
165
+ if (v.purpose !== undefined && (typeof v.purpose !== "string" || v.purpose === ""))
142
166
  return false;
143
167
  if (v.kind !== "request")
144
168
  return false;
@@ -1,27 +1,77 @@
1
1
  /**
2
2
  * 0.1.40 (R-C item 3) — the truncation guard: a runtime adapter wrapper.
3
3
  *
4
- * The reference implementation's protection: a truncated stream (stopReason
5
- * max_tokens/length) can
6
- * yield tool args that parse and validate but are silently incomplete —
7
- * executing them is the destructive-bug class. The provider adapters already
8
- * see the stop reason; the RUNTIME vetoes execution of the whole batch:
9
- *
10
- * - tool_call_end events are held per turn (the deltas pass through live —
11
- * the UI still shows the calls building, only the COMPLETION is gated);
12
- * - a compatible stop flushes the held calls in order, then the stop the
13
- * kernel launches them exactly as before (the parallel window survives;
14
- * only the 0.1.26 mid-stream launch timing is gone the cost of the
15
- * guarantee: the turn's truncated intent is never half-executed);
16
- * - a truncation stop flushes the held calls with input: null — the
17
- * kernel's EXISTING invalid-input denial fails the whole batch without
18
- * executing anything (the same honest null the adapters already emit
19
- * for unparseable partials zero new protocol surface), and the turn
20
- * still ends with the max_tokens terminal (the loop's voided settle).
21
- *
22
- * The kernel machinery (the streaming launch, the window, the voided
23
- * settle) is untouched the gate lives at the adapter boundary, where the
24
- * stop reason is already known.
4
+ * WHY: a truncated stream (stop reason max_tokens/length) can yield tool
5
+ * args that PARSE and VALIDATE while being silently incomplete — a delete
6
+ * whose `filter` never arrived, an edit missing its second hunk. Executing
7
+ * those is the destructive-bug class. The adapters already know the stop
8
+ * reason, so the veto belongs at that boundary.
9
+ *
10
+ * THE CONTRACT this wrapper establishes:
11
+ *
12
+ * 1. HOLD. A `tool_call_end` is withheld until the turn's stop reason is
13
+ * known. No call completes so none launches — before the provider
14
+ * has said why it stopped. The deltas still pass through live: a
15
+ * surface shows the calls building; only the COMPLETION is gated.
16
+ * 2. RELEASE ON A VALID STOP. A compatible stop flushes the held calls
17
+ * unchanged and in CALL order, then the stop itself. Downstream sees
18
+ * exactly what the provider sent.
19
+ * 3. max_tokens VOIDS THE WHOLE BATCH. Every held call is flushed with
20
+ * `input: null`, and the kernel's EXISTING invalid-input denial fails
21
+ * all of them without executing anything (the same honest null the
22
+ * adapters already emit for unparseable partials zero new protocol
23
+ * surface). The turn still ends on the max_tokens terminal (the
24
+ * loop's voided settle). All-or-nothing per turn: a truncated intent
25
+ * is never HALF executed.
26
+ * 4. NO STOP AT ALL drops the held calls entirely — the kernel already
27
+ * voids that malformed turn (invalid_request).
28
+ *
29
+ * EC-1 ④ — THE CONTRACT AMENDMENT. The kernel closed the destructive half
30
+ * of this by itself: `max_tokens` cannot carry a tool call, so a truncated
31
+ * turn never reaches Turn Commit, and a commit-required handler never starts
32
+ * before that commit. What max_tokens means now, in full — the four clauses
33
+ * that replace the old "zero tools executed on truncation" line:
34
+ *
35
+ * 1. COMMIT-REQUIRED CALLS NEVER EXECUTE. On either path, guarded or bare.
36
+ * This is the kernel's guarantee, not the wrapper's, and it holds for
37
+ * every tool that declares nothing — which is every write, edit and
38
+ * shell tool kiso ships.
39
+ * 2. PRECOMMIT-SAFE CALLS MAY ALREADY HAVE EXECUTED, and that execution is
40
+ * DECLARED HARMLESS. A tool carrying `effects.precommitSafe` says
41
+ * running it before the turn commits is harmless for EVERY invocation —
42
+ * read-only, free, local. Bare, such a call launches during the stream
43
+ * and a truncated turn may find its receipt already durable. That is
44
+ * the certificate being spent, not a leak.
45
+ * 3. THE TURN IS NOT COMMITTED. No durable stop is written. The calls are
46
+ * an uncommitted draft, which is what the resume sees.
47
+ * 4. PRECOMMIT RESULTS NEVER LEGITIMIZE IT (invariant 7). A durable
48
+ * receipt from clause 2 is an execution fact and nothing more: it does
49
+ * not commit the invocation, and it does not make the model turn valid.
50
+ *
51
+ * WHAT THE WRAPPER STILL BUYS, given all that:
52
+ *
53
+ * - REPORTING. It releases the held batch with `input: null`, so every
54
+ * call is ANSWERED with an honest invalid_input result. Bare, the same
55
+ * turn leaves its calls with no results at all — an uncommitted draft
56
+ * the resume must void.
57
+ * - THE PRECOMMIT CASE. The hold sits UPSTREAM of the kernel: a held call
58
+ * never reaches the loop until the stop is known, so clause 2's "may
59
+ * already have executed" is exactly what the guard removes. Guarded,
60
+ * nothing runs at all — not even a declared read.
61
+ *
62
+ * So the conservatism split did not disappear, it MOVED. It used to be the
63
+ * difference between a destructive edit running and not running; it is now
64
+ * the difference between a harmless read running and not running, plus the
65
+ * reporting. The kernel's default is speed for CERTIFIED calls and safety
66
+ * for everything else; the flagship runtime composes this wrapper into every
67
+ * run and pays the latency to have neither.
68
+ *
69
+ * Pinned by `packages/runtime/tests/truncation-guard.test.ts` (clauses 1-3
70
+ * of the wrapper contract, byte-unchanged across EC-1 — the amendment did
71
+ * not weaken the guard) and by
72
+ * `packages/runtime/tests/sc1-truncation-contract-pins.test.ts` (clause 4 of
73
+ * the wrapper contract, and the four amended max_tokens clauses above — the
74
+ * declared TRUNCATION CLASS).
25
75
  */
26
76
  import type { Adapter } from "@vincemakes/kiso-core";
27
77
  /** Wrap the adapter so a truncated turn's tool batch can never execute. */
@@ -1,27 +1,77 @@
1
1
  /**
2
2
  * 0.1.40 (R-C item 3) — the truncation guard: a runtime adapter wrapper.
3
3
  *
4
- * The reference implementation's protection: a truncated stream (stopReason
5
- * max_tokens/length) can
6
- * yield tool args that parse and validate but are silently incomplete —
7
- * executing them is the destructive-bug class. The provider adapters already
8
- * see the stop reason; the RUNTIME vetoes execution of the whole batch:
9
- *
10
- * - tool_call_end events are held per turn (the deltas pass through live —
11
- * the UI still shows the calls building, only the COMPLETION is gated);
12
- * - a compatible stop flushes the held calls in order, then the stop the
13
- * kernel launches them exactly as before (the parallel window survives;
14
- * only the 0.1.26 mid-stream launch timing is gone the cost of the
15
- * guarantee: the turn's truncated intent is never half-executed);
16
- * - a truncation stop flushes the held calls with input: null — the
17
- * kernel's EXISTING invalid-input denial fails the whole batch without
18
- * executing anything (the same honest null the adapters already emit
19
- * for unparseable partials zero new protocol surface), and the turn
20
- * still ends with the max_tokens terminal (the loop's voided settle).
21
- *
22
- * The kernel machinery (the streaming launch, the window, the voided
23
- * settle) is untouched the gate lives at the adapter boundary, where the
24
- * stop reason is already known.
4
+ * WHY: a truncated stream (stop reason max_tokens/length) can yield tool
5
+ * args that PARSE and VALIDATE while being silently incomplete — a delete
6
+ * whose `filter` never arrived, an edit missing its second hunk. Executing
7
+ * those is the destructive-bug class. The adapters already know the stop
8
+ * reason, so the veto belongs at that boundary.
9
+ *
10
+ * THE CONTRACT this wrapper establishes:
11
+ *
12
+ * 1. HOLD. A `tool_call_end` is withheld until the turn's stop reason is
13
+ * known. No call completes so none launches — before the provider
14
+ * has said why it stopped. The deltas still pass through live: a
15
+ * surface shows the calls building; only the COMPLETION is gated.
16
+ * 2. RELEASE ON A VALID STOP. A compatible stop flushes the held calls
17
+ * unchanged and in CALL order, then the stop itself. Downstream sees
18
+ * exactly what the provider sent.
19
+ * 3. max_tokens VOIDS THE WHOLE BATCH. Every held call is flushed with
20
+ * `input: null`, and the kernel's EXISTING invalid-input denial fails
21
+ * all of them without executing anything (the same honest null the
22
+ * adapters already emit for unparseable partials zero new protocol
23
+ * surface). The turn still ends on the max_tokens terminal (the
24
+ * loop's voided settle). All-or-nothing per turn: a truncated intent
25
+ * is never HALF executed.
26
+ * 4. NO STOP AT ALL drops the held calls entirely — the kernel already
27
+ * voids that malformed turn (invalid_request).
28
+ *
29
+ * EC-1 ④ — THE CONTRACT AMENDMENT. The kernel closed the destructive half
30
+ * of this by itself: `max_tokens` cannot carry a tool call, so a truncated
31
+ * turn never reaches Turn Commit, and a commit-required handler never starts
32
+ * before that commit. What max_tokens means now, in full — the four clauses
33
+ * that replace the old "zero tools executed on truncation" line:
34
+ *
35
+ * 1. COMMIT-REQUIRED CALLS NEVER EXECUTE. On either path, guarded or bare.
36
+ * This is the kernel's guarantee, not the wrapper's, and it holds for
37
+ * every tool that declares nothing — which is every write, edit and
38
+ * shell tool kiso ships.
39
+ * 2. PRECOMMIT-SAFE CALLS MAY ALREADY HAVE EXECUTED, and that execution is
40
+ * DECLARED HARMLESS. A tool carrying `effects.precommitSafe` says
41
+ * running it before the turn commits is harmless for EVERY invocation —
42
+ * read-only, free, local. Bare, such a call launches during the stream
43
+ * and a truncated turn may find its receipt already durable. That is
44
+ * the certificate being spent, not a leak.
45
+ * 3. THE TURN IS NOT COMMITTED. No durable stop is written. The calls are
46
+ * an uncommitted draft, which is what the resume sees.
47
+ * 4. PRECOMMIT RESULTS NEVER LEGITIMIZE IT (invariant 7). A durable
48
+ * receipt from clause 2 is an execution fact and nothing more: it does
49
+ * not commit the invocation, and it does not make the model turn valid.
50
+ *
51
+ * WHAT THE WRAPPER STILL BUYS, given all that:
52
+ *
53
+ * - REPORTING. It releases the held batch with `input: null`, so every
54
+ * call is ANSWERED with an honest invalid_input result. Bare, the same
55
+ * turn leaves its calls with no results at all — an uncommitted draft
56
+ * the resume must void.
57
+ * - THE PRECOMMIT CASE. The hold sits UPSTREAM of the kernel: a held call
58
+ * never reaches the loop until the stop is known, so clause 2's "may
59
+ * already have executed" is exactly what the guard removes. Guarded,
60
+ * nothing runs at all — not even a declared read.
61
+ *
62
+ * So the conservatism split did not disappear, it MOVED. It used to be the
63
+ * difference between a destructive edit running and not running; it is now
64
+ * the difference between a harmless read running and not running, plus the
65
+ * reporting. The kernel's default is speed for CERTIFIED calls and safety
66
+ * for everything else; the flagship runtime composes this wrapper into every
67
+ * run and pays the latency to have neither.
68
+ *
69
+ * Pinned by `packages/runtime/tests/truncation-guard.test.ts` (clauses 1-3
70
+ * of the wrapper contract, byte-unchanged across EC-1 — the amendment did
71
+ * not weaken the guard) and by
72
+ * `packages/runtime/tests/sc1-truncation-contract-pins.test.ts` (clause 4 of
73
+ * the wrapper contract, and the four amended max_tokens clauses above — the
74
+ * declared TRUNCATION CLASS).
25
75
  */
26
76
  /** Wrap the adapter so a truncated turn's tool batch can never execute. */
27
77
  export function truncationGuard(adapter) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
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",
@@ -25,11 +25,11 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.11.0"
28
+ "@vincemakes/kiso-core": "0.13.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.11.0",
32
- "@vincemakes/kiso-provider-openai": "0.11.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.13.0",
32
+ "@vincemakes/kiso-provider-openai": "0.13.0"
33
33
  },
34
34
  "peerDependenciesMeta": {
35
35
  "@vincemakes/kiso-provider-anthropic": {
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "devDependencies": {
43
- "@vincemakes/kiso-evals": "0.11.0",
43
+ "@vincemakes/kiso-evals": "0.13.0",
44
44
  "@types/node": "^26.1.2",
45
45
  "typescript": "^5.7.2",
46
46
  "vitest": "^3.0.0"