@vincemakes/kiso-runtime 0.40.1 → 0.40.3

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.
@@ -0,0 +1,18 @@
1
+ import type { StoreRecord } from "./store.js";
2
+ export interface UnansweredCall {
3
+ /** The run that owns the call — the repair rides it, so no new open run appears. */
4
+ readonly runId: string;
5
+ readonly callId: string;
6
+ /** The call's own seq (its invocation). */
7
+ readonly invocationSeq: number;
8
+ }
9
+ /** The result a repaired call receives — the kernel's own words for an
10
+ * abort that lands after the started event. */
11
+ export declare const ABORTED_BEFORE_EXECUTION = "aborted before execution";
12
+ /**
13
+ * Every committed `tool_call_end` with neither a started event nor a
14
+ * result, in a run whose terminal is `aborted`. Committed: a `stop` follows
15
+ * the call inside its turn. Voided model output (a `model_output_abandoned`
16
+ * range) is not a call at all.
17
+ */
18
+ export declare function unansweredAbortedCalls(records: readonly StoreRecord[]): UnansweredCall[];
@@ -0,0 +1,57 @@
1
+ /** The result a repaired call receives — the kernel's own words for an
2
+ * abort that lands after the started event. */
3
+ export const ABORTED_BEFORE_EXECUTION = "aborted before execution";
4
+ /**
5
+ * Every committed `tool_call_end` with neither a started event nor a
6
+ * result, in a run whose terminal is `aborted`. Committed: a `stop` follows
7
+ * the call inside its turn. Voided model output (a `model_output_abandoned`
8
+ * range) is not a call at all.
9
+ */
10
+ export function unansweredAbortedCalls(records) {
11
+ const byRun = new Map();
12
+ for (const r of records) {
13
+ const events = byRun.get(r.runId) ?? [];
14
+ events.push(r.event);
15
+ byRun.set(r.runId, events);
16
+ }
17
+ // Answered = a started event or a result for THIS invocation. Both carry
18
+ // invocationSeq today; a legacy result without it answers the latest
19
+ // earlier call with its callId (providers may reuse a callId across turns).
20
+ const answeredSeqs = new Set();
21
+ const legacy = [];
22
+ for (const r of records) {
23
+ const e = r.event;
24
+ if (e.type !== "tool_result" && e.type !== "tool_execution_started")
25
+ continue;
26
+ const invocationSeq = e.invocationSeq;
27
+ if (invocationSeq !== undefined)
28
+ answeredSeqs.add(invocationSeq);
29
+ else
30
+ legacy.push({ callId: e.callId, seq: e.seq });
31
+ }
32
+ const answered = (call) => answeredSeqs.has(call.seq) || legacy.some((l) => l.callId === call.callId && l.seq > call.seq);
33
+ const out = [];
34
+ for (const [runId, events] of byRun) {
35
+ const terminal = events.find((e) => e.type === "terminal");
36
+ if (terminal === undefined || terminal.type !== "terminal" || terminal.outcome.kind !== "aborted")
37
+ continue;
38
+ const voided = events.flatMap((e) => (e.type === "model_output_abandoned" ? [{ from: e.voidFromSeq, to: e.seq }] : []));
39
+ let turn = [];
40
+ for (const e of events) {
41
+ // an abandoned draft's calls and stop are model output that never was
42
+ if ((e.type === "tool_call_end" || e.type === "stop") && voided.some((v) => e.seq > v.from && e.seq <= v.to))
43
+ continue;
44
+ if (e.type === "tool_call_end")
45
+ turn.push(e);
46
+ else if (e.type === "stop") {
47
+ for (const call of turn)
48
+ if (!answered(call))
49
+ out.push({ runId, callId: call.callId, invocationSeq: call.seq });
50
+ turn = [];
51
+ }
52
+ else if (e.type === "user_input" || e.type === "terminal")
53
+ turn = [];
54
+ }
55
+ }
56
+ return out;
57
+ }
package/dist/agent.js CHANGED
@@ -87,6 +87,7 @@ export class AgentRuntime {
87
87
  });
88
88
  let restored = null;
89
89
  let profilePending = false;
90
+ let newSession = null;
90
91
  let driftAcknowledgement = null;
91
92
  if (meta.kind === "ok") {
92
93
  const drift = assessProfileDrift(meta.profile, {
@@ -134,8 +135,10 @@ export class AgentRuntime {
134
135
  }
135
136
  }
136
137
  else if (log.all.length === 0) {
137
- // a NEW session: revision 1 lands BEFORE any durable event.
138
- writeProfile(store.root, options.id, { ...candidate, revision: 1 });
138
+ // a NEW session. DC-60: nothing of it reaches the disk before its
139
+ // first durable event — revision 1 (with the workspace it opened in)
140
+ // lands WITH that event, still before it (session.ts persist).
141
+ newSession = { workspace: candidate.workspace };
139
142
  }
140
143
  else {
141
144
  // legacy (pre-XP log, no sidecar): generation absence is not
@@ -154,6 +157,7 @@ export class AgentRuntime {
154
157
  : {}),
155
158
  ...(restored !== null ? { reasoning: restored.reasoning } : {}),
156
159
  ...(profilePending ? { profilePending: true } : {}),
160
+ ...(newSession !== null ? { newSession } : {}),
157
161
  ...(this.#definition.profileName !== undefined ? { profileName: this.#definition.profileName } : {}),
158
162
  ...(driftAcknowledgement !== null ? { driftAcknowledgement } : {}),
159
163
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
@@ -23,6 +23,17 @@ export interface Tiers {
23
23
  readonly emergency: number;
24
24
  readonly tail: number;
25
25
  }
26
+ /** ADR-0055 Amendment 2 (decision 4): the output an endpoint may grant when
27
+ * neither kiso nor the registry states one. kiso sends no `max_tokens`
28
+ * where it sends none today, so the endpoint's own default applies — and
29
+ * the one default observed is 131,072 (the op gateway's refusal on
30
+ * 2026-09-22: "you requested 131072 output tokens" with no max_tokens
31
+ * sent). Reserving 32K there put the emergency tier 99K too high. */
32
+ export declare const UNKNOWN_MAX_OUTPUT = 131072;
33
+ /** The emergency reserve: what the request asks for when kiso sends a
34
+ * `max_tokens`, else the registry's max output for the served endpoint,
35
+ * else UNKNOWN_MAX_OUTPUT — never below the summary budget. */
36
+ export declare function outputReserve(maxTokens: number | undefined, registryMaxOutput: number | null | undefined, floor: number): number;
26
37
  export declare function tiersFor(windowTokens: number, reserve: number): Tiers;
27
38
  /**
28
39
  * A1 — which tier a measured context is past, the most urgent first; the
@@ -11,6 +11,19 @@
11
11
  */
12
12
  import { DO_NOT_COMPACT, MICROCOMPACTABLE } from "@vincemakes/kiso-core";
13
13
  import { estimateEventTokens } from "./summarize.js";
14
+ /** ADR-0055 Amendment 2 (decision 4): the output an endpoint may grant when
15
+ * neither kiso nor the registry states one. kiso sends no `max_tokens`
16
+ * where it sends none today, so the endpoint's own default applies — and
17
+ * the one default observed is 131,072 (the op gateway's refusal on
18
+ * 2026-09-22: "you requested 131072 output tokens" with no max_tokens
19
+ * sent). Reserving 32K there put the emergency tier 99K too high. */
20
+ export const UNKNOWN_MAX_OUTPUT = 131_072;
21
+ /** The emergency reserve: what the request asks for when kiso sends a
22
+ * `max_tokens`, else the registry's max output for the served endpoint,
23
+ * else UNKNOWN_MAX_OUTPUT — never below the summary budget. */
24
+ export function outputReserve(maxTokens, registryMaxOutput, floor) {
25
+ return Math.max(maxTokens ?? registryMaxOutput ?? UNKNOWN_MAX_OUTPUT, floor);
26
+ }
14
27
  export function tiersFor(windowTokens, reserve) {
15
28
  return {
16
29
  soft: Math.min(0.5 * windowTokens, 400_000),
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ADR-0055 Amendment 2 (decision 3) — the overflow belt: a runtime adapter
3
+ * wrapper, beside the idle and truncation guards.
4
+ *
5
+ * WHY: d7aa's last request carried an anchored 1,047,981 tokens to a model
6
+ * whose window is 1,048,576. The gateway answered `500 Internal server
7
+ * error` — no text a classifier could read — and the kernel, told a 5xx is
8
+ * retryable, re-sent a request that could not fit ten times: the twelve-
9
+ * minute freeze. kiso already KNEW the request could not fit. This belt
10
+ * says so: when a request is sent with the anchored context at or above
11
+ * the stated window minus the output reserve, any provider error it meets
12
+ * is `context_overflow`, not retryable, and the kernel's one compaction and
13
+ * one retry take over.
14
+ *
15
+ * It is a belt, not the cure — the cure is that the context never gets
16
+ * there (decisions 1, 2 and 4). Below the limit nothing changes: a bare
17
+ * 5xx keeps its retries. The limit is measured ONCE, at send time, from a
18
+ * STATED window only; the caller's `measure` returns null when no window is
19
+ * stated (the 200K policy fallback never arms it — keyed on the fallback, a
20
+ * session at 170K on a real 1M model would lose its retries to every
21
+ * transient 5xx) or when no bill anchors the context.
22
+ *
23
+ * A caller's abort passes through untouched: it is not the provider's error.
24
+ */
25
+ import type { Adapter } from "@vincemakes/kiso-core";
26
+ export interface OverflowMeasure {
27
+ /** The anchored context the request carries (the last bill + what was appended since). */
28
+ readonly used: number;
29
+ /** The stated window. */
30
+ readonly window: number;
31
+ /** The output the endpoint may grant (compaction-policy.ts outputReserve). */
32
+ readonly reserve: number;
33
+ }
34
+ export declare function overflowBelt(adapter: Adapter, measure: () => OverflowMeasure | null): Adapter;
@@ -0,0 +1,28 @@
1
+ export function overflowBelt(adapter, measure) {
2
+ return {
3
+ stream: (options) => {
4
+ const m = measure();
5
+ if (m === null || m.used < m.window - m.reserve)
6
+ return adapter.stream(options);
7
+ return belted(adapter, options, m);
8
+ },
9
+ };
10
+ }
11
+ async function* belted(adapter, options, m) {
12
+ try {
13
+ yield* adapter.stream(options);
14
+ }
15
+ catch (err) {
16
+ if (options.signal?.aborted === true || !isStructured(err))
17
+ throw err;
18
+ throw {
19
+ ...err,
20
+ code: "context_overflow",
21
+ retryable: false,
22
+ message: `${err.message} — the request carried ~${m.used} tokens against a stated ${m.window}-token window less a ${m.reserve}-token output reserve; it could not fit, so it is not retried`,
23
+ };
24
+ }
25
+ }
26
+ function isStructured(err) {
27
+ return err !== null && typeof err === "object" && typeof err.code === "string" && typeof err.message === "string";
28
+ }
@@ -126,6 +126,48 @@ export interface ModelMetadataEntry {
126
126
  * one matches any endpoint. Unknown model → null, never a default.
127
127
  */
128
128
  export declare function lookupModelMetadata(model: string, endpoint?: string): ModelMetadataEntry | null;
129
+ /** CW-1: the aliases, for the gate that holds each one to a dated source
130
+ * and a registered target. */
131
+ export declare function modelAliases(): Readonly<Record<string, {
132
+ readonly is: string;
133
+ readonly asOf: string;
134
+ readonly source: string;
135
+ }>>;
136
+ /** CW-1: a model id reduced to the weights it names — `deepseek/DeepSeek-V4.1-Flash`
137
+ * and `deepseek-v4.1-flash` are both `deepseek-flash`. A variant suffix
138
+ * (`:free`) is NOT folded: a variant may serve a smaller window. */
139
+ export declare function modelIdentity(model: string): string;
140
+ /** Where a resolved window came from — the source a display names. */
141
+ export type ContextWindowSource = "route" | "upstream" | "model";
142
+ export interface ResolvedContextWindow {
143
+ readonly tokens: number;
144
+ readonly source: ContextWindowSource;
145
+ /** the registry row's model id the figure was read from */
146
+ readonly from: string;
147
+ }
148
+ /**
149
+ * CW-1 (owner, 2026-09-23) — the context window for a route the registry
150
+ * has no row for.
151
+ *
152
+ * Rows are keyed by (model, endpoint) because a route can answer
153
+ * differently from the vendor — gpt-5.5 is 1,050,000 at the first-party
154
+ * API and 272,000 at the subscription backend. The same key made every
155
+ * forwarder, relay and gateway a miss: `deepseek-v4.1-flash` behind a local
156
+ * forwarder to a model gateway read `ctx ?`, and the tiers compacted a 1M model
157
+ * against the CLI's 200K fallback. A window belongs to the weights; a
158
+ * route can only cap it lower. In order:
159
+ *
160
+ * route — this model's row at this endpoint (lookupModelMetadata);
161
+ * upstream — its row at the endpoint a forwarder names as its upstream;
162
+ * model — every row of the same model identity, at any endpoint;
163
+ * where rows disagree, the SMALLEST — the one figure no row
164
+ * contradicts.
165
+ *
166
+ * A null window falls through; after the last step, null. Only the window
167
+ * resolves this way: price, reasoning wire fields and max output are
168
+ * facts about a route and stay on lookupModelMetadata.
169
+ */
170
+ export declare function lookupContextWindow(model: string, endpoint?: string, upstream?: string): ResolvedContextWindow | null;
129
171
  /**
130
172
  * XP-1 §4.2 — native-only resolution. The rules, in order:
131
173
  * default/default → NO wire fields (the byte-identity anchor — a
@@ -455,6 +455,70 @@ export function lookupModelMetadata(model, endpoint) {
455
455
  }
456
456
  return null;
457
457
  }
458
+ /**
459
+ * CW-1 — names one set of weights answers to across routes, each a dated
460
+ * vendor statement. The key is the FOLDED name a route serves (vendor
461
+ * prefix off, lower case); the value names the registry row.
462
+ */
463
+ const MODEL_ALIASES = {
464
+ // The pricing table: `deepseek-flash` IS DeepSeek-V4.1-Flash. Gateways
465
+ // serve it by the version name (`deepseek-v4.1-flash`).
466
+ "deepseek-v4.1-flash": { is: "deepseek-flash", asOf: "2026-09-17", source: "https://api-docs.deepseek.com/quick_start/pricing/" },
467
+ };
468
+ /** CW-1: the aliases, for the gate that holds each one to a dated source
469
+ * and a registered target. */
470
+ export function modelAliases() {
471
+ return MODEL_ALIASES;
472
+ }
473
+ /** CW-1: a model id reduced to the weights it names — `deepseek/DeepSeek-V4.1-Flash`
474
+ * and `deepseek-v4.1-flash` are both `deepseek-flash`. A variant suffix
475
+ * (`:free`) is NOT folded: a variant may serve a smaller window. */
476
+ export function modelIdentity(model) {
477
+ const folded = model.slice(model.lastIndexOf("/") + 1).toLowerCase();
478
+ return MODEL_ALIASES[folded]?.is ?? folded;
479
+ }
480
+ /**
481
+ * CW-1 (owner, 2026-09-23) — the context window for a route the registry
482
+ * has no row for.
483
+ *
484
+ * Rows are keyed by (model, endpoint) because a route can answer
485
+ * differently from the vendor — gpt-5.5 is 1,050,000 at the first-party
486
+ * API and 272,000 at the subscription backend. The same key made every
487
+ * forwarder, relay and gateway a miss: `deepseek-v4.1-flash` behind a local
488
+ * forwarder to a model gateway read `ctx ?`, and the tiers compacted a 1M model
489
+ * against the CLI's 200K fallback. A window belongs to the weights; a
490
+ * route can only cap it lower. In order:
491
+ *
492
+ * route — this model's row at this endpoint (lookupModelMetadata);
493
+ * upstream — its row at the endpoint a forwarder names as its upstream;
494
+ * model — every row of the same model identity, at any endpoint;
495
+ * where rows disagree, the SMALLEST — the one figure no row
496
+ * contradicts.
497
+ *
498
+ * A null window falls through; after the last step, null. Only the window
499
+ * resolves this way: price, reasoning wire fields and max output are
500
+ * facts about a route and stay on lookupModelMetadata.
501
+ */
502
+ export function lookupContextWindow(model, endpoint, upstream) {
503
+ const route = lookupModelMetadata(model, endpoint);
504
+ if (route !== null && route.capabilities.contextWindow !== null)
505
+ return { tokens: route.capabilities.contextWindow, source: "route", from: route.model };
506
+ if (upstream !== undefined) {
507
+ const up = lookupModelMetadata(model, upstream);
508
+ if (up !== null && up.capabilities.contextWindow !== null)
509
+ return { tokens: up.capabilities.contextWindow, source: "upstream", from: up.model };
510
+ }
511
+ const identity = modelIdentity(model);
512
+ let found = null;
513
+ for (const entry of ENTRIES) {
514
+ const tokens = entry.capabilities.contextWindow;
515
+ if (tokens === null || modelIdentity(entry.model) !== identity)
516
+ continue;
517
+ if (found === null || tokens < found.tokens)
518
+ found = { tokens, source: "model", from: entry.model };
519
+ }
520
+ return found;
521
+ }
458
522
  function originOf(endpoint) {
459
523
  try {
460
524
  return new URL(endpoint).origin;
package/dist/run.js CHANGED
@@ -9,6 +9,7 @@ import { resolveReasoning } from "./provider/metadata.js";
9
9
  import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
10
10
  import { composeApprovalChain, composeSystemPrompt, runBasePrompt } from "./compose.js";
11
11
  import { truncationGuard } from "./truncation-guard.js";
12
+ import { overflowBelt } from "./overflow-belt.js";
12
13
  import { DEFAULT_STREAM_IDLE_MS, idleGuard } from "./idle-guard.js";
13
14
  import { RequestTracer, traceGuard } from "./trace/guard.js";
14
15
  import { runtimeVersion } from "./trace/writer.js";
@@ -72,6 +73,11 @@ export class Run {
72
73
  // here, before any log or disk mutation.
73
74
  this.#session.ensureHealthy();
74
75
  this.#session.beginRun(this);
76
+ // DC-60: a resume of a session with no durable event has nothing to
77
+ // recover — and writes nothing (no trace header, no summary row) for
78
+ // a session that never began. The CLI runs one at every start.
79
+ if (this.#resume && this.#session.log.all.length === 0)
80
+ return;
75
81
  const log = this.#session.log;
76
82
  // The static prompt parts — computed ONCE, before the tracer, and
77
83
  // reused for both the composed string (below) and the rent ledger
@@ -145,7 +151,10 @@ export class Run {
145
151
  // LT-1: the idle guard sits closest to the adapter — a stall is
146
152
  // aborted and thrown there, the truncation guard and the tracer see
147
153
  // the same events and the same error every other transport failure shows them.
148
- adapter: traceGuard(tracer, truncationGuard(idleGuard(this.#adapter, this.#config.streamIdleMs ?? DEFAULT_STREAM_IDLE_MS))), // tracer assigned above, before loopConfig
154
+ // ADR-0055 Amendment 2 (decision 3): the overflow belt sits outside
155
+ // the guards, so a stall or a cut on a request known not to fit is
156
+ // classified as the overflow it is, and the tracer records that.
157
+ adapter: traceGuard(tracer, overflowBelt(truncationGuard(idleGuard(this.#adapter, this.#config.streamIdleMs ?? DEFAULT_STREAM_IDLE_MS)), () => this.#session.overflowMeasure())), // tracer assigned above, before loopConfig
149
158
  model: this.#config.model,
150
159
  sessionId: this.#session.id, // P3: tools see their session (ToolContext.sessionId)
151
160
  ...(systemPrompt !== undefined ? { systemPrompt } : {}),
@@ -195,6 +204,9 @@ export class Run {
195
204
  // dangling approvals closed (permission_expired) — a dead
196
205
  // run's approval is never re-presented or resurrected.
197
206
  const records = this.#store.load(this.#session.id);
207
+ // 0.40.2: a dead run's committed calls that never ran are answered
208
+ // before anything below projects the session into a request.
209
+ await this.#session.repairAbortedCalls(records);
198
210
  const runs = new Map();
199
211
  const order = [];
200
212
  for (const r of records) {
@@ -267,10 +279,14 @@ export class Run {
267
279
  // persistence layer — a second open run would be permanently
268
280
  // orphaned (recovery only ever recovers the last one). The
269
281
  // open run is continued via resume(), never by starting another.
270
- const openRun = openRunId(this.#store.load(this.#session.id));
282
+ const records = this.#store.load(this.#session.id);
283
+ const openRun = openRunId(records);
271
284
  if (openRun !== undefined) {
272
285
  throw new Error(`session ${this.#session.id} still has an open run (${openRun}) — resume() it instead of starting a new run`);
273
286
  }
287
+ // 0.40.2: an aborted run's committed calls that never ran are
288
+ // answered before this run's first request projects them.
289
+ await this.#session.repairAbortedCalls(records);
274
290
  // E6 — the run-start context policy: BEFORE this run's
275
291
  // user_input lands, the policy may persist one `summarized`
276
292
  // fact through the existing summarize() path (the manual
package/dist/session.d.ts CHANGED
@@ -28,10 +28,11 @@
28
28
  * same package, same exports (index.ts re-exports all four).
29
29
  */
30
30
  import { EventLog, type AbortSignalLike, type Adapter, type Event, type KisoExtension, type Message, type PermissionDecision, type Tool, type ToolSpec, type EventInput } from "@vincemakes/kiso-core";
31
+ import { type OverflowMeasure } from "./overflow-belt.js";
31
32
  import { type TaskAssessment } from "./task-assessment.js";
32
33
  import { type ContinuationScope } from "@vincemakes/kiso-core";
33
34
  import { type ReasoningSetting } from "./provider/metadata.js";
34
- import { type SessionStore } from "./store.js";
35
+ import { type SessionStore, type StoreRecord } from "./store.js";
35
36
  import { Run } from "./run.js";
36
37
  /** TUI2-R3v2 ③ — one off-trajectory model request (session.sideQuery).
37
38
  * Deliberately tiny: a purpose to mark it in the trace, its own short
@@ -109,6 +110,16 @@ export declare class AgentSession {
109
110
  * the cause (stale handle, corruption, a live external writer, an I/O
110
111
  * fault) — so no further run, resume, or log mutation may proceed.
111
112
  * The health check runs BEFORE every write, on every path. */
113
+ /**
114
+ * 0.40.2 — a committed call an aborted run left with no started and no
115
+ * result gets ONE durable result, "aborted before execution", riding the
116
+ * run that owns it (aborted-calls.ts). Every path that projects the
117
+ * session into a request calls this first — a run's start (fresh or
118
+ * resumed) and /compact — so no dangling tool_use reaches a provider,
119
+ * and the already-poisoned logs heal with no migration. Idempotent per
120
+ * invocation: a crash between two repairs leaves the rest for next time.
121
+ */
122
+ repairAbortedCalls(records: readonly StoreRecord[]): Promise<void>;
112
123
  persist(runId: string, event: Event): Promise<void>;
113
124
  beginRun(run: Run): void;
114
125
  endRun(run: Run): void;
@@ -208,6 +219,9 @@ export declare class AgentSession {
208
219
  * every open.
209
220
  */
210
221
  setMicrocompactThreshold(thresholdTokens: number): void;
222
+ /** ADR-0055 Amendment 2 (decision 3): the overflow belt's measure at send
223
+ * time — null unless a window is STATED and a bill anchors the context. */
224
+ overflowMeasure(): OverflowMeasure | null;
211
225
  /** A1b: the window the in-run tiers are drawn from, for a restored or
212
226
  * re-bound session (the CLI's one binding step calls both). */
213
227
  setContextWindow(windowTokens: number): void;
@@ -474,6 +488,27 @@ export interface ContextPolicy {
474
488
  readonly windowTokens: number;
475
489
  readonly isCheck?: (command: string) => boolean;
476
490
  readonly maxFailures?: number;
491
+ /** ADR-0055 Amendment 2: told when a checkpoint is discarded because it
492
+ * did not shrink the context — chars/4 estimates only, never text. */
493
+ readonly onDiscard?: (info: {
494
+ readonly reason: string;
495
+ readonly pre: number;
496
+ readonly post: number;
497
+ readonly summary: number;
498
+ }) => void;
499
+ /** ADR-0055 Amendment 2 (decision 3): the window someone STATED for the
500
+ * live binding, or null when the tiers run on a fallback. Absent, the
501
+ * caller's own windowTokens counts as stated. Only a stated window
502
+ * arms the overflow belt. */
503
+ readonly statedWindow?: () => number | null;
504
+ /** CW-1 batch 2: told when an endpoint's refusal states its cap — the
505
+ * binding it was learned for rides along. The tiers have already
506
+ * taken it (only ever down); the caller keeps it for later sessions. */
507
+ readonly onWindowLearned?: (learned: {
508
+ readonly tokens: number;
509
+ readonly model: string;
510
+ readonly baseUrl?: string;
511
+ }) => void;
477
512
  };
478
513
  }
479
514
  export interface SessionConfig {
@@ -494,6 +529,11 @@ export interface SessionConfig {
494
529
  readonly reasoning?: import("./provider/metadata.js").ReasoningSetting;
495
530
  /** XP-1 internal: a legacy session's deferred revision-1 write. */
496
531
  readonly profilePending?: true;
532
+ /** DC-60 internal: a NEW session — nothing is on disk yet; its revision 1
533
+ * (with the workspace it opened in) lands with its first durable event. */
534
+ readonly newSession?: {
535
+ readonly workspace: string | null;
536
+ };
497
537
  /** 0.40.0: the config profile name in force — recorded per revision. */
498
538
  readonly profileName?: string;
499
539
  /** 0.40.0: set when this open acknowledged material drift. */
package/dist/session.js CHANGED
@@ -27,8 +27,10 @@
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, projectMessages, } from "@vincemakes/kiso-core";
30
+ import { EventLog, projectMessages, SUMMARY_FRAMING, } from "@vincemakes/kiso-core";
31
31
  import { executionLedger } from "./ledger.js";
32
+ import { overflowBelt } from "./overflow-belt.js";
33
+ import { windowLearner } from "./window-learner.js";
32
34
  import { assessTasks } from "./task-assessment.js";
33
35
  /** TV-1A — the session-level evidence policy: the PURE projection defaults
34
36
  * to ∅ (never inventing evidence); the session names the one built-in
@@ -47,7 +49,8 @@ import { estimateTokens } from "@vincemakes/kiso-core";
47
49
  import { StaleWriterError } from "./store.js";
48
50
  import { composeHooks, composeSystemPrompt, microcompactFor, runBasePrompt } from "./compose.js";
49
51
  import { checkpointBoundarySeq } from "./checkpoint.js";
50
- import { breakEvenFactor, guardedPruneSeq, KEEP_COMPACTABLE_RESULTS, microcompactBoundarySeq, phaseEnd, runsACheck, tierReason, tiersFor } from "./compaction-policy.js";
52
+ import { ABORTED_BEFORE_EXECUTION, unansweredAbortedCalls } from "./aborted-calls.js";
53
+ import { breakEvenFactor, guardedPruneSeq, KEEP_COMPACTABLE_RESULTS, microcompactBoundarySeq, outputReserve, phaseEnd, runsACheck, tierReason, tiersFor } from "./compaction-policy.js";
51
54
  import { Run } from "./run.js";
52
55
  // TUI2-R3v2 ③ — the side query rides the SAME tracer the runs ride; that
53
56
  // sameness is the whole point (one ledger, one shape, no second path).
@@ -124,6 +127,8 @@ export class AgentSession {
124
127
  // XP-1: a legacy session records revision 1 at the next explicit
125
128
  // selection or first request — never eagerly at open.
126
129
  #profilePending;
130
+ /** DC-60: set until a NEW session's first durable event lands. */
131
+ #newSession;
127
132
  /** 0.40.0: the config profile name of the live binding (configuration:
128
133
  * each revision records the name in force when it is written). */
129
134
  #profileName;
@@ -197,6 +202,7 @@ export class AgentSession {
197
202
  // CTX-1: starts as the startup policy, then follows the binding.
198
203
  this.#microcompact = config.microcompact;
199
204
  this.#profilePending = config.profilePending === true;
205
+ this.#newSession = config.newSession;
200
206
  this.#profileName = config.profileName ?? null;
201
207
  this.driftAcknowledgement = config.driftAcknowledgement ?? null;
202
208
  }
@@ -221,9 +227,31 @@ export class AgentSession {
221
227
  * the cause (stale handle, corruption, a live external writer, an I/O
222
228
  * fault) — so no further run, resume, or log mutation may proceed.
223
229
  * The health check runs BEFORE every write, on every path. */
230
+ /**
231
+ * 0.40.2 — a committed call an aborted run left with no started and no
232
+ * result gets ONE durable result, "aborted before execution", riding the
233
+ * run that owns it (aborted-calls.ts). Every path that projects the
234
+ * session into a request calls this first — a run's start (fresh or
235
+ * resumed) and /compact — so no dangling tool_use reaches a provider,
236
+ * and the already-poisoned logs heal with no migration. Idempotent per
237
+ * invocation: a crash between two repairs leaves the rest for next time.
238
+ */
239
+ async repairAbortedCalls(records) {
240
+ for (const c of unansweredAbortedCalls(records)) {
241
+ const ev = this.log.append({ type: "tool_result", callId: c.callId, invocationSeq: c.invocationSeq, content: ABORTED_BEFORE_EXECUTION, isError: true, errorKind: "precondition" });
242
+ await this.persist(c.runId, ev);
243
+ }
244
+ }
224
245
  async persist(runId, event) {
225
246
  this.ensureHealthy();
226
247
  try {
248
+ // DC-60: a NEW session's revision 1 lands with its first durable
249
+ // event and BEFORE it — a log line never exists without its profile.
250
+ if (this.#newSession !== undefined) {
251
+ const { workspace } = this.#newSession;
252
+ this.#newSession = undefined;
253
+ this.#writeProfileRevision(workspace);
254
+ }
227
255
  await this.#store.append(this.id, runId, event);
228
256
  if (event.type === "usage" && event.known)
229
257
  this.#lastUsageAt = Date.now();
@@ -293,16 +321,20 @@ export class AgentSession {
293
321
  return beforeSeq === undefined ? [] : [{ type: "microcompacted", beforeSeq }];
294
322
  }
295
323
  const window = this.#tiersWindow ?? tiersPolicy.windowTokens;
296
- const maxOutput = this.#config.maxTokens ?? lookupModelMetadata(this.#model, this.#baseUrl)?.capabilities.maxOutputTokens ?? 0;
297
- const t = tiersFor(window, Math.max(maxOutput, MANUAL_SUMMARY_BUDGET));
324
+ const t = tiersFor(window, this.#outputReserve());
298
325
  const isCheck = tiersPolicy.isCheck ?? ((command) => runsACheck(command));
299
326
  const reason = tierReason(used, t, why, () => phaseEnd(events, lastSummaryPoint(events), isCheck));
300
327
  if (reason === null)
301
328
  return [];
302
329
  const urgent = reason === "overflow" || reason === "emergency";
303
- if (!urgent && this.#summaryFailures >= (tiersPolicy.maxFailures ?? MAX_SUMMARY_FAILURES))
330
+ // ADR-0055 Amendment 2: the breaker stands at every tier but
331
+ // overflow (once per request by construction). An urgent tier that
332
+ // meets it skips the summary call and keeps the prune fallback — a
333
+ // session whose checkpoints cannot shrink it stops paying for them.
334
+ const breakerOpen = reason !== "overflow" && this.#summaryFailures >= (tiersPolicy.maxFailures ?? MAX_SUMMARY_FAILURES);
335
+ if (breakerOpen && !urgent)
304
336
  return [];
305
- const summarized = await this.#summarizeInRun(events, messages, t.tail, reason, run);
337
+ const summarized = breakerOpen ? null : await this.#summarizeInRun(events, messages, t.tail, reason, run, tiersPolicy.onDiscard);
306
338
  if (summarized !== null)
307
339
  return [summarized];
308
340
  if (!urgent)
@@ -321,8 +353,9 @@ export class AgentSession {
321
353
  * abort is never answered with the second call. Throws when both fail.
322
354
  */
323
355
  async #checkpointCall(args) {
356
+ const learned = this.#learning(this.#adapter);
324
357
  const common = {
325
- adapter: this.#adapter,
358
+ adapter: learned,
326
359
  model: this.#model,
327
360
  maxOutputTokens: args.budget,
328
361
  ...(args.signal !== undefined ? { signal: args.signal } : {}),
@@ -333,7 +366,11 @@ export class AgentSession {
333
366
  ...(args.onProgress !== undefined ? { onProgress: args.onProgress } : {}),
334
367
  };
335
368
  try {
336
- const result = await summarizeConversation({ ...common, messages: args.messages, inBand: args.inBand, ...(args.reasoning !== undefined ? { reasoning: args.reasoning } : {}) });
369
+ // The in-band call carries the run's whole context, so the overflow
370
+ // belt applies to it; the serialised fallback below is small, and
371
+ // its failures keep their own classification.
372
+ const inBandAdapter = overflowBelt(learned, () => this.overflowMeasure());
373
+ const result = await summarizeConversation({ ...common, adapter: inBandAdapter, messages: args.messages, inBand: args.inBand, ...(args.reasoning !== undefined ? { reasoning: args.reasoning } : {}) });
337
374
  return { result, path: "in-band" };
338
375
  }
339
376
  catch (err) {
@@ -353,7 +390,7 @@ export class AgentSession {
353
390
  /** A2 — one in-run summary: in-band first, the serialised form once on
354
391
  * a rejection; null when neither produced a valid checkpoint. The
355
392
  * summary call's usage and path ride the trace ledger, as /compact's do. */
356
- async #summarizeInRun(events, messages, tail, reason, run) {
393
+ async #summarizeInRun(events, messages, tail, reason, run, onDiscard) {
357
394
  const boundary = checkpointBoundarySeq(events, { keepTokens: tail });
358
395
  if (boundary === undefined)
359
396
  return null;
@@ -375,20 +412,43 @@ export class AgentSession {
375
412
  this.#summaryFailures += 1;
376
413
  return null;
377
414
  }
378
- this.#summaryFailures = 0;
415
+ // ADR-0055 Amendment 2 (the shrink invariant): a fire is kept only if
416
+ // it removes at least what it writes — post ≤ pre − summary, all by
417
+ // the chars/4 estimate over the SAME projection (never the bill
418
+ // against an estimate). A checkpoint that fails it is discarded:
419
+ // nothing is appended, the run goes on as before, and the failure
420
+ // counts toward the breaker.
421
+ const fire = { type: "summarized", coversToSeq: boundary, summary: result.text };
422
+ const pre = estimateTokens(projectMessages(events));
423
+ const post = estimateTokens(projectMessages([...events, { ...fire, seq: (events.at(-1)?.seq ?? -1) + 1 }]));
424
+ const written = estimateTokens([{ role: "user", content: `${SUMMARY_FRAMING}\n\n${result.text}` }]);
425
+ const shrinks = pre - post >= written;
379
426
  // Every in-run fire is recorded — its reason and path are what the
380
- // measurement counts — and its usage when the provider reported one.
427
+ // measurement counts — and its usage when the provider reported one;
428
+ // a discarded one too (it was paid for), marked so.
381
429
  {
382
430
  try {
383
431
  const canonical = result.usage === null ? null : canonicalizeUsageForModel(this.#model, this.#baseUrl, this.#provider ?? "adapter", result.usage);
384
432
  mkdirSync(join(this.#store.root, "traces"), { recursive: true, mode: 0o700 });
385
- appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), `${JSON.stringify({ kind: "summary", canonical, reason, path })}\n`, { mode: 0o600 });
433
+ appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), `${JSON.stringify({ kind: "summary", canonical, reason, path, ...(shrinks ? {} : { discarded: "no-shrink" }) })}\n`, { mode: 0o600 });
386
434
  }
387
435
  catch (err) {
388
436
  console.error(`[kiso] summary usage ledger degraded (${err instanceof Error ? err.message : String(err)}); the summary call's cost is not recorded`);
389
437
  }
390
438
  }
391
- return { type: "summarized", coversToSeq: boundary, summary: result.text };
439
+ if (!shrinks) {
440
+ this.#summaryFailures += 1;
441
+ // sizes only — the checkpoint's text is the owner's work
442
+ try {
443
+ onDiscard?.({ reason, pre, post, summary: written });
444
+ }
445
+ catch {
446
+ // observation only — a notice that cannot be shown changes nothing
447
+ }
448
+ return null;
449
+ }
450
+ this.#summaryFailures = 0;
451
+ return fire;
392
452
  }
393
453
  /**
394
454
  * merge round B (/model): replace the adapter for SUBSEQUENT runs. The
@@ -451,6 +511,41 @@ export class AgentSession {
451
511
  setMicrocompactThreshold(thresholdTokens) {
452
512
  this.#microcompact = { thresholdTokens };
453
513
  }
514
+ /** CW-1 batch 2: the adapter as a run or a summary uses it — a refusal
515
+ * that states a cap is learned for the binding in force NOW, the one
516
+ * this adapter belongs to (a /model mid-run must not receive it). */
517
+ #learning(adapter) {
518
+ const at = { model: this.#model, baseUrl: this.#baseUrl };
519
+ return windowLearner(adapter, (tokens) => this.#learnWindow(tokens, at));
520
+ }
521
+ /** CW-1 batch 2: a route stated its cap. The in-run tiers take it at once
522
+ * — the kernel's one overflow compaction runs next and must aim at the
523
+ * real window, not past it — and only ever DOWN; the caller is told, to
524
+ * keep it for the next session and to say so. */
525
+ #learnWindow(tokens, at) {
526
+ const tiers = this.#config.contextPolicy?.tiers;
527
+ if (at.model === this.#model && at.baseUrl === this.#baseUrl) {
528
+ const current = this.#tiersWindow ?? tiers?.windowTokens;
529
+ if (current === undefined || tokens < current)
530
+ this.#tiersWindow = tokens;
531
+ }
532
+ tiers?.onWindowLearned?.({ tokens, model: at.model, ...(at.baseUrl !== undefined ? { baseUrl: at.baseUrl } : {}) });
533
+ }
534
+ /** ADR-0055 Amendment 2 (decision 3): the overflow belt's measure at send
535
+ * time — null unless a window is STATED and a bill anchors the context. */
536
+ overflowMeasure() {
537
+ const tiers = this.#config.contextPolicy?.tiers;
538
+ if (tiers === undefined)
539
+ return null;
540
+ const window = tiers.statedWindow !== undefined ? tiers.statedWindow() : (this.#tiersWindow ?? tiers.windowTokens);
541
+ const used = this.contextAnchor();
542
+ return window === null || used === undefined ? null : { used, window, reserve: this.#outputReserve() };
543
+ }
544
+ /** ADR-0055 Amendment 2 (decision 4): the emergency reserve — what the
545
+ * endpoint may grant for this binding (compaction-policy.ts). */
546
+ #outputReserve() {
547
+ return outputReserve(this.#config.maxTokens, lookupModelMetadata(this.#model, this.#baseUrl)?.capabilities.maxOutputTokens, MANUAL_SUMMARY_BUDGET);
548
+ }
454
549
  /** The raw tail a manual cut at a settled round keeps: the tiers' tail
455
550
  * when a window is known, else the policy's keep floor. */
456
551
  #manualTailTokens() {
@@ -507,6 +602,10 @@ export class AgentSession {
507
602
  * recovery, projection or a request reads it.
508
603
  */
509
604
  recordSummary(open) {
605
+ // DC-60: a session with no durable event has no row — writing one made
606
+ // a session that never began appear in the list.
607
+ if (this.log.all.length === 0)
608
+ return;
510
609
  try {
511
610
  const prior = readProfile(this.#store.root, this.id);
512
611
  writeSummary(this.#store.root, this.id, summarizeEvents(this.log.all, {
@@ -522,8 +621,18 @@ export class AgentSession {
522
621
  }
523
622
  }
524
623
  /** XP-1: record the live binding as the next durable profile revision
525
- * (read-modify-write under the session's single-writer ownership). */
624
+ * (read-modify-write under the session's single-writer ownership).
625
+ * DC-60: a NEW session keeps the binding in memory until its first
626
+ * durable event, which records it (persist). */
526
627
  #recordProfile() {
628
+ if (this.#newSession !== undefined)
629
+ return;
630
+ this.#writeProfileRevision(null);
631
+ }
632
+ /** The next profile revision from the live binding. `startWorkspace` is
633
+ * the workspace a NEW session opened in; with no prior revision and no
634
+ * start (a legacy session's first revision) the start is unknown: null. */
635
+ #writeProfileRevision(startWorkspace) {
527
636
  const prior = readProfile(this.#store.root, this.id);
528
637
  const revision = prior.kind === "ok" ? prior.profile.revision + 1 : 1;
529
638
  writeProfile(this.#store.root, this.id, buildProfile({
@@ -532,9 +641,9 @@ export class AgentSession {
532
641
  provider: this.#continuationScope ?? null,
533
642
  profileName: this.#profileName,
534
643
  // 0.40.0: history is CARRIED from the prior revision, never
535
- // re-derived from this process. With no prior (a legacy
536
- // session's first revision) the start is unknown: null.
537
- workspace: prior.kind === "ok" ? prior.profile.workspace : null,
644
+ // re-derived from this process. With no prior, a NEW session's
645
+ // start is the workspace it opened in; a legacy one's is unknown.
646
+ workspace: prior.kind === "ok" ? prior.profile.workspace : startWorkspace,
538
647
  reasoning: this.#reasoning,
539
648
  ...(this.#config.systemPrompt !== undefined ? { systemPrompt: this.#config.systemPrompt } : {}),
540
649
  registry: this.#config.registry,
@@ -633,7 +742,7 @@ export class AgentSession {
633
742
  this.ensureHealthy();
634
743
  if (this.#profilePending)
635
744
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
636
- return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, input, options?.signal, false, options?.source, options?.via);
745
+ return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, input, options?.signal, false, options?.source, options?.via);
637
746
  }
638
747
  /**
639
748
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -643,7 +752,7 @@ export class AgentSession {
643
752
  */
644
753
  resume() {
645
754
  this.ensureHealthy();
646
- return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, undefined, undefined, true);
755
+ return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, undefined, undefined, true);
647
756
  }
648
757
  /**
649
758
  * /compact (ADR-0044): compress the older conversation with a model
@@ -675,6 +784,7 @@ export class AgentSession {
675
784
  const cancelled = () => new Error("the compaction was cancelled");
676
785
  if (options.signal !== undefined && options.signal.aborted)
677
786
  throw cancelled();
787
+ await this.repairAbortedCalls(this.#store.load(this.id));
678
788
  const events = this.log.all;
679
789
  // ADR-0055 (the owner's dogfood): a long autonomous session has few
680
790
  // user turns and many tool rounds, and the user-turn cut found nothing
@@ -138,13 +138,13 @@ export declare function serializeCovered(options: SerializeCoveredOptions): stri
138
138
  * The fixed English summary prompt — the ONLY prompt this layer composes
139
139
  * (the loop's system prompt is the harness's business, never the kernel's).
140
140
  */
141
- export declare const SUMMARY_PROMPT = "Only output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it in the model's context. The next turn must be able\nto continue the work without reading the originals.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user message in the covered range, enumerated one by one, each\nwith what it asked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
141
+ export declare const SUMMARY_PROMPT = "Only output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it \u2014 and every earlier checkpoint \u2014 in the model's\ncontext. The next turn must be able to continue the work without reading\nthe originals. If the conversation includes an earlier checkpoint, it is\nbeing replaced too: carry forward everything in it that is still true,\nso that this checkpoint restates the whole task.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user request of the task \u2014 those an earlier checkpoint lists and\nthose in the covered range \u2014 enumerated one by one, each with what it\nasked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
142
142
  /**
143
143
  * ADR-0055 A2 — the IN-BAND form: the run's own request with this appended
144
144
  * as the last user message, so everything before it reads at the cache-hit
145
145
  * price. The same sections, the same validation; the guard sentence first.
146
146
  */
147
- export declare const SUMMARY_IN_BAND = "Stop the task for this one reply and write a checkpoint of the conversation above instead.\n\nOnly output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it in the model's context. The next turn must be able\nto continue the work without reading the originals.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user message in the covered range, enumerated one by one, each\nwith what it asked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
147
+ export declare const SUMMARY_IN_BAND = "Stop the task for this one reply and write a checkpoint of the conversation above instead.\n\nOnly output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it \u2014 and every earlier checkpoint \u2014 in the model's\ncontext. The next turn must be able to continue the work without reading\nthe originals. If the conversation includes an earlier checkpoint, it is\nbeing replaced too: carry forward everything in it that is still true,\nso that this checkpoint restates the whole task.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user request of the task \u2014 those an earlier checkpoint lists and\nthose in the covered range \u2014 enumerated one by one, each with what it\nasked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
148
148
  /**
149
149
  * E6 (b) — the output-side validation (the finding E6-F4/F5 follow-up):
150
150
  * a summary must be a complete checkpoint or NOTHING. The marker family
package/dist/summarize.js CHANGED
@@ -122,16 +122,20 @@ export const MAX_SUMMARY_FAILURES = 3;
122
122
  export function serializeCovered(options) {
123
123
  const { events, prevPoint, boundary } = options;
124
124
  const lines = ["<conversation>"];
125
- // E6 (d) (the order's R4): the old summary texts are RETAINED CONTEXT —
126
- // the durable record of the earlier ranges. They render first, labeled
127
- // do-not-re-summarize: the summarizer must know what the earlier
128
- // summaries covered, but never fold them into the new checkpoint.
129
- const retained = events.filter((e) => e.type === "summarized" && e.coversToSeq <= prevPoint);
130
- if (retained.length > 0) {
131
- lines.push("[retained context — do not re-summarize]");
132
- for (const r of retained)
133
- lines.push(`[summary covers to seq ${r.coversToSeq}] ${r.summary}`);
134
- lines.push("[end retained context]");
125
+ // ADR-0055 Amendment 2 (E6 (d)'s "do not re-summarize" retired): the
126
+ // new checkpoint REPLACES the previous one, so the summariser is given
127
+ // exactly the checkpoint the projection shows — the latest at or before
128
+ // prevPoint — as material to fold in. The superseded ones are not shown:
129
+ // the latest already restates them.
130
+ let previous;
131
+ for (const e of events) {
132
+ if (e.type === "summarized" && e.coversToSeq <= prevPoint && (previous === undefined || e.coversToSeq >= previous.coversToSeq))
133
+ previous = e;
134
+ }
135
+ if (previous !== undefined) {
136
+ lines.push("[previous checkpoint — the new checkpoint replaces it: carry forward everything in it that is still true]");
137
+ lines.push(previous.summary);
138
+ lines.push("[end previous checkpoint]");
135
139
  }
136
140
  for (const ev of events) {
137
141
  if (ev.seq <= prevPoint || ev.seq > boundary || ev.type === "summarized")
@@ -173,8 +177,11 @@ export const SUMMARY_PROMPT = `${SUMMARY_GUARD}
173
177
  You are the conversation summarizer of the kiso agent framework.
174
178
 
175
179
  Summarize the covered conversation into a single structured checkpoint
176
- that will REPLACE it in the model's context. The next turn must be able
177
- to continue the work without reading the originals.
180
+ that will REPLACE it — and every earlier checkpoint — in the model's
181
+ context. The next turn must be able to continue the work without reading
182
+ the originals. If the conversation includes an earlier checkpoint, it is
183
+ being replaced too: carry forward everything in it that is still true,
184
+ so that this checkpoint restates the whole task.
178
185
 
179
186
  Produce the checkpoint with exactly these sections, in this order:
180
187
 
@@ -185,8 +192,9 @@ The user's goal and the acceptance criterion, in one or two sentences.
185
192
  The constraints, requirements, and rulings the work must honor.
186
193
 
187
194
  ## User requests
188
- Every user message in the covered range, enumerated one by one, each
189
- with what it asked for and what was done about it.
195
+ Every user request of the task — those an earlier checkpoint lists and
196
+ those in the covered range — enumerated one by one, each with what it
197
+ asked for and what was done about it.
190
198
 
191
199
  ## Files and changes
192
200
  Every file touched — exact paths, what changed, and why. Include the
@@ -0,0 +1,26 @@
1
+ /**
2
+ * CW-1 batch 2 — a route's real window, read from its own refusal.
3
+ *
4
+ * Batch 1 gives a registered model a window at any endpoint, and at an
5
+ * endpoint no row names that window is the model's — a vendor statement
6
+ * about the weights, which a relay may cap lower. The one source that is
7
+ * right for ANY route is the route itself, and a route that refuses an
8
+ * oversize request usually states its cap. d4fc (2026-09-22) died on:
9
+ *
10
+ * This model's maximum context length is 1048576 tokens. However, you
11
+ * requested 131072 output tokens and your prompt contains at least
12
+ * 917505 input tokens ...
13
+ *
14
+ * The kernel already compacts once and retries once on context_overflow;
15
+ * what it lacked is the figure, so the compaction and every request after
16
+ * it aimed at the wrong window. This wrapper reads the figure off the
17
+ * refusal and hands it on, then rethrows the error untouched — it changes
18
+ * no classification and no retry.
19
+ */
20
+ import type { Adapter } from "@vincemakes/kiso-core";
21
+ /** The cap a refusal states, or null — anything that does not say one in
22
+ * the two wordings above is null, never a guess. */
23
+ export declare function overflowCap(message: string): number | null;
24
+ /** Wraps an adapter so a refusal that states a cap is reported once, and
25
+ * then thrown on exactly as it came. A caller's abort passes untouched. */
26
+ export declare function windowLearner(adapter: Adapter, onCap: (tokens: number) => void): Adapter;
@@ -0,0 +1,40 @@
1
+ /** The two wordings the providers kiso speaks to use for the cap:
2
+ * OpenAI-shaped and vLLM-shaped servers (and gateways in front of them)
3
+ * say `maximum context length is N tokens`; Anthropic-shaped ones say
4
+ * `prompt is too long: A tokens > N maximum`. */
5
+ const CAP_PATTERNS = [/maximum context length is\s+(\d[\d,_]*)\s*tokens/i, /prompt is too long:\s*\d[\d,_]*\s*tokens\s*>\s*(\d[\d,_]*)\s*maximum/i];
6
+ /** A window below this, or above the second bound, is not a window a
7
+ * refusal would state — a figure outside them is a misread, never a cap. */
8
+ const MIN_CAP = 1_000;
9
+ const MAX_CAP = 100_000_000;
10
+ /** The cap a refusal states, or null — anything that does not say one in
11
+ * the two wordings above is null, never a guess. */
12
+ export function overflowCap(message) {
13
+ for (const re of CAP_PATTERNS) {
14
+ const m = re.exec(message);
15
+ if (m === null)
16
+ continue;
17
+ const n = Number(m[1].replace(/[,_]/g, ""));
18
+ return Number.isSafeInteger(n) && n >= MIN_CAP && n <= MAX_CAP ? n : null;
19
+ }
20
+ return null;
21
+ }
22
+ /** Wraps an adapter so a refusal that states a cap is reported once, and
23
+ * then thrown on exactly as it came. A caller's abort passes untouched. */
24
+ export function windowLearner(adapter, onCap) {
25
+ return { stream: (options) => learning(adapter, options, onCap) };
26
+ }
27
+ async function* learning(adapter, options, onCap) {
28
+ try {
29
+ yield* adapter.stream(options);
30
+ }
31
+ catch (err) {
32
+ if (options.signal?.aborted !== true) {
33
+ const message = typeof err === "object" && err !== null && typeof err.message === "string" ? err.message : null;
34
+ const cap = message === null ? null : overflowCap(message);
35
+ if (cap !== null)
36
+ onCap(cap);
37
+ }
38
+ throw err;
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.40.1",
3
+ "version": "0.40.3",
4
4
  "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,12 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.40.1"
28
+ "@vincemakes/kiso-core": "0.40.3"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.40.1",
32
- "@vincemakes/kiso-provider-openai": "0.40.1",
33
- "@vincemakes/kiso-provider-openai-responses": "0.40.1"
31
+ "@vincemakes/kiso-provider-anthropic": "0.40.3",
32
+ "@vincemakes/kiso-provider-openai": "0.40.3",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.40.3"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@vincemakes/kiso-provider-anthropic": {
@@ -44,7 +44,7 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@vincemakes/kiso-evals": "0.40.1",
47
+ "@vincemakes/kiso-evals": "0.40.3",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"