@vincemakes/kiso-runtime 0.47.3 → 0.49.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
@@ -28,7 +28,22 @@ export function composeToolTable(registry, rules = []) {
28
28
  "- end your turn with your findings — never end on a bare tool result",
29
29
  ...tools.flatMap((t) => (t.promptSnippet === undefined ? [] : [`- ${t.promptSnippet}`])),
30
30
  ];
31
- const guidelines = tools.flatMap((t) => (t.promptGuidelines ?? []).map((g) => `- ${t.name}: ${g}`));
31
+ // Plan B: one statement per fact — a guideline body several tools carry
32
+ // prints ONCE, naming every tool that carries it, in first-seen order.
33
+ // The names stay: a host's or an extension's guideline may need its
34
+ // tool's name to read unambiguously.
35
+ const byBody = new Map();
36
+ for (const t of tools) {
37
+ for (const g of t.promptGuidelines ?? []) {
38
+ const body = g.trim();
39
+ const names = byBody.get(body);
40
+ if (names === undefined)
41
+ byBody.set(body, [t.name]);
42
+ else if (!names.includes(t.name))
43
+ names.push(t.name);
44
+ }
45
+ }
46
+ const guidelines = [...byBody].map(([body, names]) => `- ${names.join(", ")}: ${body}`);
32
47
  if (guidelines.length > 0)
33
48
  lines.push("Active tool guidelines:", ...guidelines);
34
49
  return lines.join("\n");
@@ -167,6 +167,14 @@ const chatgptRow = (model, opts = {}) => ({
167
167
  const OPENAI_MODELS_ASOF_2 = "2026-09-09";
168
168
  const CHATGPT_PRESETS_SOURCE_2 = "https://github.com/openai/codex/blob/634ebc1865c6ac840ed3ba118f040d527bf4b55d/codex-rs/models-manager/models.json";
169
169
  const ULTRA_LADDER = ["low", "medium", "high", "xhigh", "max", "ultra"];
170
+ /** 0.48.0 (finding 0473-F2): the presets at a later commit, read 2026-10-09.
171
+ * The owner's `sol` profile moved to `gpt-6.1-sol` on 2026-10-08, and with
172
+ * no row kiso sent it no effort: the remembered `high` never reached the
173
+ * wire and no reasoning summary came back. The id answered on the
174
+ * subscription backend that night (the 0.47.3 live sessions). No
175
+ * first-party row: no model page was read for it. */
176
+ const OPENAI_MODELS_ASOF_3 = "2026-10-09";
177
+ const CHATGPT_PRESETS_SOURCE_3 = "https://github.com/openai/codex/blob/d63a9b8344cfe58bc78bbe319b560378fc8756ef/codex-rs/models-manager/models.json";
170
178
  /** The v1 table. Nulls outnumber numbers ON PURPOSE: only values with a
171
179
  * named source enter; everything else waits for one. */
172
180
  const ENTRIES = [
@@ -409,6 +417,7 @@ const ENTRIES = [
409
417
  openaiRow("gpt-5.6-sol", "medium", { inputPerM: 4, outputPerM: 20, cacheReadPerM: 0.4 }, "https://developers.openai.com/api/docs/models/gpt-5.6-sol", { levels: ["none", "low", "medium", "high", "xhigh", "max"], asOf: OPENAI_MODELS_ASOF_2 }),
410
418
  chatgptRow("gpt-6-astra", { levels: ULTRA_LADDER, default: "low", source: CHATGPT_PRESETS_SOURCE_2, asOf: OPENAI_MODELS_ASOF_2 }),
411
419
  chatgptRow("gpt-5.6-sol", { levels: ULTRA_LADDER, default: "low", source: CHATGPT_PRESETS_SOURCE_2, asOf: OPENAI_MODELS_ASOF_2 }),
420
+ chatgptRow("gpt-6.1-sol", { levels: ULTRA_LADDER, default: "low", source: CHATGPT_PRESETS_SOURCE_3, asOf: OPENAI_MODELS_ASOF_3 }),
412
421
  // The compat table's SECOND row (roadmap R3.2 §1a step 1): GLM 5.3 Flash
413
422
  // through OpenRouter, read from OpenRouter's models API on 2026-09-09. The
414
423
  // id is OpenRouter's, so the row is keyed to that origin; the
package/dist/run.js CHANGED
@@ -7,7 +7,7 @@ import { denialResult, loop, validateArgs } from "@vincemakes/kiso-core";
7
7
  import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
8
8
  import { resolveReasoning } from "./provider/metadata.js";
9
9
  import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
10
- import { appendOf, composeApprovalChain, joinPrompt, promptPerAttempt, runBasePrompt } from "./compose.js";
10
+ import { appendOf, composeApprovalChain, composeToolTable, joinPrompt, promptPerAttempt, runBasePrompt } from "./compose.js";
11
11
  import { truncationGuard } from "./truncation-guard.js";
12
12
  import { overflowBelt } from "./overflow-belt.js";
13
13
  import { DEFAULT_STREAM_IDLE_MS, idleGuard } from "./idle-guard.js";
@@ -212,7 +212,11 @@ export class Run {
212
212
  // that layer, records the bytes the model was sent. The in-band
213
213
  // summary reads the last attempt's snapshot (the prefix the run
214
214
  // last sent) or takes one if no attempt has happened yet.
215
- const rentParts = { ...(this.#config.systemPrompt !== undefined ? { base: this.#config.systemPrompt } : {}), appends: [] };
215
+ // RG-F2: the generated table is a ledger line of its own — the
216
+ // same text runBasePrompt placed between the base and the appends
217
+ // (one registry read at the same instant, so it cannot differ).
218
+ const table = (this.#config.toolTable ?? "on") === "on" ? composeToolTable(this.#config.registry, this.#config.toolRules ?? []) : "";
219
+ const rentParts = { ...(this.#config.systemPrompt !== undefined ? { base: this.#config.systemPrompt } : {}), ...(table !== "" ? { table } : {}), appends: [] };
216
220
  let lastPrompt;
217
221
  let taken = false;
218
222
  const snapshot = () => {
package/dist/session.d.ts CHANGED
@@ -130,7 +130,7 @@ export declare class AgentSession {
130
130
  /** Deliver this session's task transitions to the model: into a live
131
131
  * run at its next safe point, or — idle — held for the next run, or one
132
132
  * continuation run through `onWake` (off with `wake: false`). */
133
- useTasks(manager: TaskManager, options?: Pick<TaskDeliveryOptions, "wake" | "onWake" | "windowMs" | "onLost"> & {
133
+ useTasks(manager: TaskManager, options?: Pick<TaskDeliveryOptions, "wake" | "onWake" | "windowMs" | "maxWakes" | "onLost" | "onBudgetSpent"> & {
134
134
  readonly autoDetachMinAgeMs?: number;
135
135
  }): () => void;
136
136
  /** ADR-0057 §5 (3e D1) — a person's steer arrived: the foreground
@@ -256,6 +256,10 @@ export declare class AgentSession {
256
256
  * binding, restored from the durable profile on open. The status row
257
257
  * reads THIS, so the row and the request can never disagree. */
258
258
  get model(): string;
259
+ /** 0.49.0 C1: the config profile that named the live binding — null for
260
+ * a direct provider/model or an SDK caller. Moves with every binding,
261
+ * so a delegated child can run on the conversation's own profile. */
262
+ get profileName(): string | null;
259
263
  /** A1a: the parts of the NEXT request as the kernel would assemble them —
260
264
  * the system prompt, the tool table (the same snapshot the loop sends:
261
265
  * live extension tools included), the projected messages with their
package/dist/session.js CHANGED
@@ -654,6 +654,12 @@ export class AgentSession {
654
654
  get model() {
655
655
  return this.#model;
656
656
  }
657
+ /** 0.49.0 C1: the config profile that named the live binding — null for
658
+ * a direct provider/model or an SDK caller. Moves with every binding,
659
+ * so a delegated child can run on the conversation's own profile. */
660
+ get profileName() {
661
+ return this.#profileName;
662
+ }
657
663
  /** A1a: the parts of the NEXT request as the kernel would assemble them —
658
664
  * the system prompt, the tool table (the same snapshot the loop sends:
659
665
  * live extension tools included), the projected messages with their
@@ -15,8 +15,8 @@
15
15
  * Exactly once, from the log: a transition counts as delivered when a
16
16
  * `user_input` whose `via.items` names it is in the session's log (ADR-0051
17
17
  * Amendment 8). The set is a cache rebuilt from the log, never a second
18
- * truth. Lineage from the log too: a task started inside a wake run (a run
19
- * whose first input is a task notice) never wakes — depth 1.
18
+ * truth. The chain budget from the log too (ADR-0058 Amendment 9): the wake
19
+ * runs since the last run a person started are counted, never kept.
20
20
  *
21
21
  * The summary snapshot is what the MODEL was told, never the live journal:
22
22
  * a compaction must not reveal a transition that has not been delivered.
@@ -37,18 +37,31 @@
37
37
  * 16 KiB together; members already delivered are named as reported, and
38
38
  * only the rest are receipted. A failure goes into a live run at once and
39
39
  * never wakes on its own; idle, it waits for its group.
40
+ *
41
+ * 0.49.0 A: a delegate call that waits for its children (the join)
42
+ * CLAIMS each one that ended while it waited, and its own result carries
43
+ * that child's handoff. Such a member is delivered once the claiming
44
+ * call's result is durable — an error result included, since it carries
45
+ * the handoffs too — and the group's notice names it as reported. A claim
46
+ * whose call never returned (a crash: the kernel records no result, only
47
+ * an uncertain execution) delivered nothing, so the member rides its
48
+ * group's notice in full: never in both places, never in neither.
40
49
  */
41
50
  import { type Event, type UserInputVia } from "@vincemakes/kiso-core";
42
51
  import { type Run } from "../run.js";
43
52
  import { type TaskInfo, type TaskManager } from "./manager.js";
44
53
  export interface TaskDeliveryOptions {
45
54
  readonly manager: TaskManager;
46
- /** The session's durable events: receipts and lineage are read here. */
55
+ /** The session's durable events: receipts and the chain are read here. */
47
56
  readonly events: () => readonly Event[];
48
57
  /** The session's live run, when one is running. */
49
58
  readonly liveRun: () => Run | undefined;
50
59
  /** False turns every wake into a notify (the switch, ADR-0058 §8.4). */
51
60
  readonly wake?: boolean;
61
+ /** ADR-0058 Amendment 9 — the chain budget: how many autonomous wakes may follow
62
+ * one person's input before a terminal delivers as a notify instead.
63
+ * Replaces ADR-0058 §8 guard 3 (lineage depth 1). Default 20. */
64
+ readonly maxWakes?: number;
52
65
  /** Start ONE continuation run whose first input is this notice. */
53
66
  readonly onWake?: (input: {
54
67
  readonly content: string;
@@ -60,11 +73,22 @@ export interface TaskDeliveryOptions {
60
73
  * startup or as it is heard — the person can be told at once, while
61
74
  * the model's notice keeps its own turn. A claimed loss never comes. */
62
75
  readonly onLost?: (taskIds: readonly string[]) => void;
76
+ /** Finding 0480-F9: the chain budget is spent — a task's end that would
77
+ * have woken the session only notifies. Called ONCE per exhaustion (the
78
+ * first such end since the last run a person started; a resume whose log
79
+ * already carries the sentence is not told again), so the person can be
80
+ * told why the chain went quiet. The model's notice says it either way. */
81
+ readonly onBudgetSpent?: (info: {
82
+ readonly wakes: number;
83
+ readonly maxWakes: number;
84
+ readonly taskIds: readonly string[];
85
+ }) => void;
63
86
  /** How much of a failed task's output rides its notice. Default 2 KB. */
64
87
  readonly tailBytes?: number;
65
88
  /** Read a task's output tail (injectable for tests). */
66
89
  readonly readTail?: (task: TaskInfo, bytes: number) => string;
67
90
  }
91
+ export declare const DEFAULT_MAX_WAKES = 20;
68
92
  export declare class TaskDelivery {
69
93
  #private;
70
94
  constructor(options: TaskDeliveryOptions);
@@ -15,8 +15,8 @@
15
15
  * Exactly once, from the log: a transition counts as delivered when a
16
16
  * `user_input` whose `via.items` names it is in the session's log (ADR-0051
17
17
  * Amendment 8). The set is a cache rebuilt from the log, never a second
18
- * truth. Lineage from the log too: a task started inside a wake run (a run
19
- * whose first input is a task notice) never wakes — depth 1.
18
+ * truth. The chain budget from the log too (ADR-0058 Amendment 9): the wake
19
+ * runs since the last run a person started are counted, never kept.
20
20
  *
21
21
  * The summary snapshot is what the MODEL was told, never the live journal:
22
22
  * a compaction must not reveal a transition that has not been delivered.
@@ -37,16 +37,30 @@
37
37
  * 16 KiB together; members already delivered are named as reported, and
38
38
  * only the rest are receipted. A failure goes into a live run at once and
39
39
  * never wakes on its own; idle, it waits for its group.
40
+ *
41
+ * 0.49.0 A: a delegate call that waits for its children (the join)
42
+ * CLAIMS each one that ended while it waited, and its own result carries
43
+ * that child's handoff. Such a member is delivered once the claiming
44
+ * call's result is durable — an error result included, since it carries
45
+ * the handoffs too — and the group's notice names it as reported. A claim
46
+ * whose call never returned (a crash: the kernel records no result, only
47
+ * an uncertain execution) delivered nothing, so the member rides its
48
+ * group's notice in full: never in both places, never in neither.
40
49
  */
41
50
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
42
51
  import { dirname, join } from "node:path";
43
52
  import { isRuntimeInput } from "@vincemakes/kiso-core";
44
53
  import { NOTICE_FOOTER as FOOTER } from "../run.js";
54
+ import { CHILD_HANDOFF_BYTES, GROUP_HANDOFF_BYTES, handoffBody, handoffRecordOf, TAIL_BYTES } from "./handoff.js";
45
55
  import { endTransitionOf } from "./manager.js";
46
56
  const key = (i) => `${i.taskId}:${i.transition}`;
47
- /** ADR-0058 3d (D3): a child's excerpt, and a group's excerpts together. */
48
- const CHILD_EXCERPT_BYTES = 4_096;
49
- const GROUP_EXCERPT_BYTES = 16_384;
57
+ export const DEFAULT_MAX_WAKES = 20;
58
+ /** The model's sentence for a spent budget (the text 0.48.0 shipped). */
59
+ const spentSentence = (wakes) => `chain budget spent: ${wakes} autonomous wakes since the person's last message — continue when they speak`;
60
+ /** ADR-0058 3d (D3): a child's excerpt, and a group's excerpts together —
61
+ * the handoff format's budgets (0.49.0 C3). */
62
+ const CHILD_EXCERPT_BYTES = CHILD_HANDOFF_BYTES;
63
+ const GROUP_EXCERPT_BYTES = GROUP_HANDOFF_BYTES;
50
64
  export class TaskDelivery {
51
65
  #o;
52
66
  #receipts = new Set();
@@ -61,6 +75,8 @@ export class TaskDelivery {
61
75
  /** ended agent tasks waiting for their group (restart: heard at startup) */
62
76
  #ended = new Map();
63
77
  #recheck = null;
78
+ /** the person-run the budget was last said spent in (0480-F9) */
79
+ #spentEpoch = null;
64
80
  #unsubscribe;
65
81
  constructor(options) {
66
82
  this.#o = options;
@@ -74,8 +90,10 @@ export class TaskDelivery {
74
90
  const item = itemOf(task);
75
91
  if (item === null || item.transition === "ready" || this.#receipts.has(key(item)))
76
92
  continue;
77
- // a tool result reported it: the claim alone decides (Amendment 7)
78
- if (task.claims?.some((c) => c.transition === item.transition) === true)
93
+ // a tool result reported it: the claim alone decides (Amendment 7) —
94
+ // for an agent task, once its claiming call's result is durable (0.49.0 A)
95
+ const claim = task.claims?.find((c) => c.transition === item.transition);
96
+ if (claim !== undefined && (task.agent === undefined || this.#toldByResult(claim.executionId)))
79
97
  continue;
80
98
  if (item.transition === "unknown")
81
99
  lost.push(task.id);
@@ -166,7 +184,8 @@ export class TaskDelivery {
166
184
  const mode = this.#modeOf(task, item);
167
185
  if (mode === "silent")
168
186
  return;
169
- this.#buffer.push({ item, line: this.#line(task, item), mode });
187
+ const spent = mode === "notify" && this.#eligible(task, item) ? this.#spent([task.id]) : null;
188
+ this.#buffer.push({ item, line: spent !== null ? `${this.#line(task, item)}\n${spent}` : this.#line(task, item), mode });
170
189
  if (this.#timer === null)
171
190
  this.#timer = setTimeout(() => this.#flush(), this.#o.windowMs ?? 1_000);
172
191
  }
@@ -221,7 +240,7 @@ export class TaskDelivery {
221
240
  }
222
241
  this.#scanned = events.length;
223
242
  }
224
- /** ADR-0058 §8 defaults, then the guards: the switch, and lineage. */
243
+ /** ADR-0058 §8 defaults, then the guards: the switch, and the chain budget. */
225
244
  #modeOf(task, item) {
226
245
  if (item.transition === "ready" || item.transition === "stopped" || item.transition === "unknown")
227
246
  return "notify";
@@ -232,17 +251,82 @@ export class TaskDelivery {
232
251
  return this.#wakes(item) ? "wake" : "notify";
233
252
  }
234
253
  #wakes(item) {
235
- if (this.#o.wake === false)
236
- return false;
237
- if (item.transition !== "exited" && item.transition !== "failed")
238
- return false;
239
254
  const task = this.#o.manager.get(item.taskId);
240
- if (task === undefined || task.executionId === undefined || task.profile === "service")
255
+ if (task === undefined || !this.#eligible(task, item))
241
256
  return false;
242
257
  // a child wakes only with its whole group (D2): never on its own
243
258
  if (task.agent !== undefined && !this.#complete(this.#groupOf(task)))
244
259
  return false;
245
- return !this.#startedInWakeRun(task.executionId);
260
+ return this.#underBudget();
261
+ }
262
+ /** The transition could wake, budget aside: the switch, a terminal, an
263
+ * execution's task, not a service. */
264
+ #eligible(task, item) {
265
+ if (this.#o.wake === false)
266
+ return false;
267
+ if (item.transition !== "exited" && item.transition !== "failed")
268
+ return false;
269
+ if (task.executionId === undefined || task.profile === "service")
270
+ return false;
271
+ return true;
272
+ }
273
+ /** ADR-0058 Amendment 9: the chain budget, derived from the log — never a
274
+ * counter. The count is the wake runs (first input a runtime notice)
275
+ * since the last run a person started. */
276
+ #underBudget() {
277
+ return this.#wakesSinceLastPerson() < (this.#o.maxWakes ?? DEFAULT_MAX_WAKES);
278
+ }
279
+ /** 0480-F9: the sentence when the budget is spent (null while it is not),
280
+ * and the person's hook, once per exhaustion. */
281
+ #spent(taskIds) {
282
+ if (this.#underBudget())
283
+ return null;
284
+ const wakes = this.#wakesSinceLastPerson();
285
+ const epoch = this.#personEpoch();
286
+ if (this.#spentEpoch !== epoch) {
287
+ this.#spentEpoch = epoch;
288
+ if (!this.#spentInLog(epoch))
289
+ this.#o.onBudgetSpent?.({ wakes, maxWakes: this.#o.maxWakes ?? DEFAULT_MAX_WAKES, taskIds });
290
+ }
291
+ return spentSentence(wakes);
292
+ }
293
+ /** The index of the last run's first input a person started (−1: none). */
294
+ #personEpoch() {
295
+ const events = this.#o.events();
296
+ let epoch = -1;
297
+ let firstOfRun = true;
298
+ for (let i = 0; i < events.length; i++) {
299
+ const e = events[i];
300
+ if (e.type === "terminal") {
301
+ firstOfRun = true;
302
+ continue;
303
+ }
304
+ if (e.type !== "user_input" || !firstOfRun)
305
+ continue;
306
+ firstOfRun = false;
307
+ if (!isRuntimeInput(e))
308
+ epoch = i;
309
+ }
310
+ return epoch;
311
+ }
312
+ /** A resume: the log already carries the sentence after that run. */
313
+ #spentInLog(epoch) {
314
+ return this.#o.events().slice(epoch + 1).some((e) => e.type === "user_input" && typeof e.content === "string" && e.content.includes("chain budget spent:"));
315
+ }
316
+ #wakesSinceLastPerson() {
317
+ let wakes = 0;
318
+ let firstOfRun = true;
319
+ for (const e of this.#o.events()) {
320
+ if (e.type === "terminal") {
321
+ firstOfRun = true;
322
+ continue;
323
+ }
324
+ if (e.type !== "user_input" || !firstOfRun)
325
+ continue;
326
+ firstOfRun = false;
327
+ wakes = isRuntimeInput(e) ? wakes + 1 : 0;
328
+ }
329
+ return wakes;
246
330
  }
247
331
  /** ADR-0058 3d: an agent task has ended — a failure goes into a live run
248
332
  * at once; every end waits for its group. */
@@ -318,9 +402,11 @@ export class TaskDelivery {
318
402
  return { closed: to < events.length && (runEnded || [...calls].every((c) => answered.has(c))), members: members.includes(task.id) ? members : [...members, task.id] };
319
403
  }
320
404
  #complete(group) {
405
+ // 0.49.0 B: a member's END, not its process's — a writer being
406
+ // collected has not ended, so its group is not complete yet
321
407
  return group.closed && group.members.every((id) => {
322
- const kind = this.#o.manager.get(id)?.state.kind;
323
- return kind === undefined || kind === "ended" || kind === "unknown" || kind === "not_run";
408
+ const task = this.#o.manager.get(id);
409
+ return task === undefined || endTransitionOf(task) !== null;
324
410
  });
325
411
  }
326
412
  /** ONE notice for a complete group: every member's line, excerpts within
@@ -332,10 +418,19 @@ export class TaskDelivery {
332
418
  const tasks = members.map((id) => this.#o.manager.get(id)).filter((t) => t !== undefined);
333
419
  const fresh = [];
334
420
  const lines = [];
421
+ const unreturned = new Set();
335
422
  for (const task of tasks) {
336
423
  const item = itemOf(task);
337
424
  if (item === null)
338
425
  continue;
426
+ // 0.49.0 A: its delegate call's own result carried it
427
+ const claim = task.claims?.find((c) => c.transition === item.transition);
428
+ if (claim !== undefined && this.#toldByResult(claim.executionId)) {
429
+ lines.push(`<kiso-task id="${task.id}" kind="agent" role="${task.agent?.role ?? ""}" status="${item.transition}" reported="earlier"/>`);
430
+ continue;
431
+ }
432
+ if (claim !== undefined)
433
+ unreturned.add(task.id);
339
434
  if (this.#receipts.has(key(item)) || this.#inFlight.has(key(item))) {
340
435
  lines.push(`<kiso-task id="${task.id}" kind="agent" role="${task.agent?.role ?? ""}" status="${item.transition}" reported="earlier"/>`);
341
436
  continue;
@@ -349,11 +444,16 @@ export class TaskDelivery {
349
444
  for (const { task, item } of fresh) {
350
445
  const excerpt = excerptOf(task, Math.min(CHILD_EXCERPT_BYTES, budget));
351
446
  budget -= excerpt.bytes;
352
- lines.push(`${this.#agentTag(task, item)}${excerpt.text}`);
447
+ lines.push(`${this.#agentTag(task, item, unreturned.has(task.id))}${excerpt.text}`);
353
448
  }
354
449
  const items = fresh.map((f) => f.item);
355
450
  const notice = { lines, items };
356
451
  const wake = !restart && items.some((i) => this.#wakes(i));
452
+ // 0480-F9: a group that could have woken but for the budget says why,
453
+ // as a single task's end does (0.48.0 delivered it silently)
454
+ const spent = !restart && !wake && fresh.some(({ task, item }) => this.#eligible(task, item)) ? this.#spent(fresh.map((f) => f.task.id)) : null;
455
+ if (spent !== null)
456
+ lines.push(spent);
357
457
  const run = this.#o.liveRun();
358
458
  if (run !== undefined && this.#hand(run, notice, wake))
359
459
  return;
@@ -361,6 +461,12 @@ export class TaskDelivery {
361
461
  this.#pending.push({ notice, wake });
362
462
  this.#pump();
363
463
  }
464
+ /** 0.49.0 A: the call that claimed a child returned — its tool_result is
465
+ * durable (error or not: it carries the handoffs either way). A crashed
466
+ * call has none: the kernel records an uncertain execution instead. */
467
+ #toldByResult(executionId) {
468
+ return this.#o.events().some((e) => e.type === "tool_result" && e.executionId === executionId);
469
+ }
364
470
  /** handed to a run, or held for one, or already in the log */
365
471
  #known(k) {
366
472
  return this.#receipts.has(k) || this.#inFlight.has(k) || this.#pending.some((p) => p.notice.items.some((i) => key(i) === k));
@@ -370,8 +476,10 @@ export class TaskDelivery {
370
476
  #unpend(k) {
371
477
  this.#pending = this.#pending.filter((p) => !(p.notice.items.length === 1 && key(p.notice.items[0]) === k));
372
478
  }
373
- #agentTag(task, item) {
479
+ #agentTag(task, item, unreturned = false) {
374
480
  const attrs = [`id="${task.id}"`, `kind="agent"`, `role="${task.agent?.role ?? ""}"`, `status="${item.transition}"`];
481
+ if (unreturned)
482
+ attrs.push(`note="its delegate call never returned"`);
375
483
  const outcome = outcomeOf(task);
376
484
  if (outcome !== undefined)
377
485
  attrs.push(`outcome="${outcome}"`);
@@ -379,26 +487,11 @@ export class TaskDelivery {
379
487
  attrs.push(`session="${task.agent.session}"`);
380
488
  if (task.endedAt !== undefined)
381
489
  attrs.push(`duration="${duration(task.endedAt - task.startedAt)}"`);
490
+ // I5: the answer is named only once the record commits it
382
491
  const result = resultPath(task);
383
- attrs.push(existsSync(result) ? `result="${result}"` : `output="${task.outputPath}"`);
492
+ attrs.push(recordOf(task) !== null && existsSync(result) ? `result="${result}"` : `output="${task.outputPath}"`);
384
493
  return `<kiso-task ${attrs.join(" ")}/>`;
385
494
  }
386
- /** Lineage depth 1: the run that started the task began with a notice. */
387
- #startedInWakeRun(executionId) {
388
- const events = this.#o.events();
389
- const at = events.findIndex((e) => e.type === "tool_execution_started" && e.executionId === executionId);
390
- if (at < 0)
391
- return false;
392
- let start = 0;
393
- for (let n = at - 1; n >= 0; n--) {
394
- if (events[n].type === "terminal") {
395
- start = n + 1;
396
- break;
397
- }
398
- }
399
- const first = events.slice(start, at).find((e) => e.type === "user_input");
400
- return isRuntimeInput(first);
401
- }
402
495
  #line(task, item) {
403
496
  const attrs = [`id="${task.id}"`, `status="${item.transition}"`];
404
497
  if (task.state.kind === "ended" && task.state.exitCode !== null)
@@ -433,42 +526,62 @@ function itemOf(task, transition) {
433
526
  return end === null ? null : { taskId: task.id, transition: end };
434
527
  }
435
528
  const resultPath = (task) => join(dirname(task.outputPath), "result.md");
436
- /** The child's outcome, as it wrote it beside its answer. */
529
+ /** The child's outcome, as its result record commits it. */
437
530
  function outcomeOf(task) {
531
+ return recordOf(task)?.outcome;
532
+ }
533
+ /** The child's result record (result.json) — the commit of its result (I5). */
534
+ function recordOf(task) {
535
+ let raw;
438
536
  try {
439
- const parsed = JSON.parse(readFileSync(join(dirname(task.outputPath), "result.json"), "utf8"));
440
- return typeof parsed.outcome === "string" ? parsed.outcome : undefined;
537
+ raw = readFileSync(join(dirname(task.outputPath), "result.json"), "utf8");
441
538
  }
442
539
  catch {
443
- return undefined;
540
+ raw = null;
444
541
  }
542
+ return handoffRecordOf(raw);
445
543
  }
446
- /** A child's answer (result.md) cut to `bytes`, or — with no answer — the
447
- * tail of what it printed. `bytes` counts the excerpt, never the pointer. */
544
+ /** A child's handoff (the format in handoff.ts): its error, then its
545
+ * answer (result.md, read only beside a valid result.json — I5) cut to
546
+ * `bytes`, or — with no answer — the tail of what it printed. `bytes`
547
+ * counts what is shown, never the pointer. */
448
548
  function excerptOf(task, bytes) {
449
- const path = resultPath(task);
450
- let answer;
451
- try {
452
- answer = readFileSync(path, "utf8").trimEnd();
549
+ const record = recordOf(task);
550
+ let answer = "";
551
+ if (record !== null) {
552
+ try {
553
+ answer = readFileSync(resultPath(task), "utf8");
554
+ }
555
+ catch {
556
+ answer = "";
557
+ }
453
558
  }
454
- catch {
455
- const tail = bytes > 0 ? readTail(task, Math.min(bytes, 2_048)) : "";
456
- return tail === "" ? { text: "", bytes: 0 } : { text: `\n${tail}`, bytes: Buffer.byteLength(tail) };
457
- }
458
- if (answer === "")
459
- return { text: "", bytes: 0 };
460
- const size = Buffer.byteLength(answer);
461
- if (size <= bytes)
462
- return { text: `\n${answer}`, bytes: size };
463
- if (bytes <= 0)
464
- return { text: `\n[the whole answer: ${path}]`, bytes: 0 };
465
- const cut = Buffer.from(answer).subarray(0, bytes).toString("utf8").replace(/\uFFFD+$/, "");
466
- return { text: `\n${cut}\n… [truncated; the whole answer: ${path}]`, bytes: Buffer.byteLength(cut) };
559
+ const tail = rawTail(task.outputPath, TAIL_BYTES);
560
+ return handoffBody({ record, answer, tail: tail.text, tailCut: tail.cut, resultPath: resultPath(task) }, bytes);
467
561
  }
468
562
  function duration(ms) {
469
563
  const s = Math.max(0, Math.round(ms / 1000));
470
564
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
471
565
  }
566
+ /** The last `bytes` of a file as read, and whether it held more. */
567
+ function rawTail(path, bytes) {
568
+ try {
569
+ const fd = openSync(path, "r");
570
+ try {
571
+ const size = fstatSync(fd).size;
572
+ const n = Math.min(size, bytes);
573
+ const buf = Buffer.alloc(n);
574
+ readSync(fd, buf, 0, n, size - n);
575
+ return { text: buf.toString("utf8"), cut: size > n };
576
+ }
577
+ finally {
578
+ closeSync(fd);
579
+ }
580
+ }
581
+ catch {
582
+ return { text: "", cut: false };
583
+ }
584
+ }
472
585
  function readTail(task, bytes) {
473
586
  try {
474
587
  const fd = openSync(task.outputPath, "r");
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The handoff format (ADR-0058, 0.49.0 C3/I7): what the model reads of one
3
+ * child, byte for byte the same whether the parent waited for it (the
4
+ * delegate tool's section, rendered by the subagent extension) or heard of
5
+ * it later (the group notice, rendered here). The extension carries its own
6
+ * copy, since it has no runtime dependency. The corpus in
7
+ * tests/fixtures/handoff holds both copies to the same expected bytes.
8
+ *
9
+ * The result record (result.json) commits a child's result (I5): the
10
+ * answer (result.md) is read only beside a valid record. Without one, the
11
+ * child's output tail is all there is (I6: nothing is inferred from its
12
+ * session's runs).
13
+ *
14
+ * Budgets count UTF-8 bytes of what is shown, never the pointers:
15
+ * the error line at most ERROR_BYTES, the tail at most TAIL_BYTES, and all
16
+ * of it within the budget the caller passes (CHILD_HANDOFF_BYTES, or what
17
+ * is left of GROUP_HANDOFF_BYTES).
18
+ */
19
+ export declare const CHILD_HANDOFF_BYTES = 4096;
20
+ export declare const GROUP_HANDOFF_BYTES = 16384;
21
+ export declare const TAIL_BYTES = 2048;
22
+ export declare const ERROR_BYTES = 1024;
23
+ export interface HandoffRecord {
24
+ readonly outcome: string;
25
+ readonly error?: string;
26
+ }
27
+ export interface HandoffInput {
28
+ /** result.json, parsed; null when it is absent or unreadable */
29
+ readonly record: HandoffRecord | null;
30
+ /** result.md's text ("" when absent) — shown only beside a record */
31
+ readonly answer: string;
32
+ /** the last bytes of the child's output, as read (at most TAIL_BYTES) */
33
+ readonly tail: string;
34
+ /** whether the output held more than `tail` */
35
+ readonly tailCut: boolean;
36
+ readonly resultPath: string;
37
+ }
38
+ /** The record a result.json holds, or null — the commit test (I5). */
39
+ export declare function handoffRecordOf(raw: string | null): HandoffRecord | null;
40
+ /** One child's handoff body: lines each led by "\n", and the bytes shown. */
41
+ export declare function handoffBody(input: HandoffInput, budget: number): {
42
+ readonly text: string;
43
+ readonly bytes: number;
44
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The handoff format (ADR-0058, 0.49.0 C3/I7): what the model reads of one
3
+ * child, byte for byte the same whether the parent waited for it (the
4
+ * delegate tool's section, rendered by the subagent extension) or heard of
5
+ * it later (the group notice, rendered here). The extension carries its own
6
+ * copy, since it has no runtime dependency. The corpus in
7
+ * tests/fixtures/handoff holds both copies to the same expected bytes.
8
+ *
9
+ * The result record (result.json) commits a child's result (I5): the
10
+ * answer (result.md) is read only beside a valid record. Without one, the
11
+ * child's output tail is all there is (I6: nothing is inferred from its
12
+ * session's runs).
13
+ *
14
+ * Budgets count UTF-8 bytes of what is shown, never the pointers:
15
+ * the error line at most ERROR_BYTES, the tail at most TAIL_BYTES, and all
16
+ * of it within the budget the caller passes (CHILD_HANDOFF_BYTES, or what
17
+ * is left of GROUP_HANDOFF_BYTES).
18
+ */
19
+ export const CHILD_HANDOFF_BYTES = 4_096;
20
+ export const GROUP_HANDOFF_BYTES = 16_384;
21
+ export const TAIL_BYTES = 2_048;
22
+ export const ERROR_BYTES = 1_024;
23
+ /** The record a result.json holds, or null — the commit test (I5). */
24
+ export function handoffRecordOf(raw) {
25
+ if (raw === null)
26
+ return null;
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ if (typeof parsed.outcome !== "string" || parsed.outcome === "")
30
+ return null;
31
+ return typeof parsed.error === "string" && parsed.error !== "" ? { outcome: parsed.outcome, error: parsed.error } : { outcome: parsed.outcome };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** One child's handoff body: lines each led by "\n", and the bytes shown. */
38
+ export function handoffBody(input, budget) {
39
+ let text = "";
40
+ let used = 0;
41
+ const room = () => Math.max(0, budget - used);
42
+ if (input.record?.error !== undefined) {
43
+ const line = headOf(`error: ${input.record.error.replace(/\s*\n\s*/g, " ").trim()}`, Math.min(ERROR_BYTES, room()));
44
+ if (line.text !== "") {
45
+ text += `\n${line.text}${line.cut ? "…" : ""}`;
46
+ used += Buffer.byteLength(line.text);
47
+ }
48
+ }
49
+ const answer = input.record === null ? "" : input.answer.trimEnd();
50
+ if (answer !== "") {
51
+ if (Buffer.byteLength(answer) <= room())
52
+ return { text: `${text}\n${answer}`, bytes: used + Buffer.byteLength(answer) };
53
+ if (room() === 0)
54
+ return { text: `${text}\n[the whole answer: ${input.resultPath}]`, bytes: used };
55
+ const cut = headOf(answer, room()).text;
56
+ return { text: `${text}\n${cut}\n… [truncated; the whole answer: ${input.resultPath}]`, bytes: used + Buffer.byteLength(cut) };
57
+ }
58
+ const tail = input.tail.trimEnd();
59
+ if (tail !== "") {
60
+ const kept = tailOf(tail, Math.min(TAIL_BYTES, room()));
61
+ if (kept.text !== "") {
62
+ text += `\n${input.tailCut || kept.cut ? "…" : ""}${kept.text}`;
63
+ used += Buffer.byteLength(kept.text);
64
+ }
65
+ }
66
+ return { text, bytes: used };
67
+ }
68
+ /** The first `bytes` of `s`, never splitting a character. */
69
+ function headOf(s, bytes) {
70
+ const buf = Buffer.from(s, "utf8");
71
+ if (buf.length <= bytes)
72
+ return { text: s, cut: false };
73
+ return { text: buf.subarray(0, Math.max(0, bytes)).toString("utf8").replace(/�+$/, ""), cut: true };
74
+ }
75
+ /** The last `bytes` of `s`, never splitting a character. */
76
+ function tailOf(s, bytes) {
77
+ const buf = Buffer.from(s, "utf8");
78
+ if (buf.length <= bytes)
79
+ return { text: s, cut: false };
80
+ return { text: buf.subarray(buf.length - Math.max(0, bytes)).toString("utf8").replace(/^�+/, ""), cut: true };
81
+ }
@@ -27,7 +27,13 @@ export interface TaskLaunch {
27
27
  export interface TaskAgent {
28
28
  readonly role: string;
29
29
  readonly session: string;
30
+ /** 0.49.0 B: a writer — its end is its COLLECTION. The process ending
31
+ * is not the result: the parent collects what it left (a patch, an
32
+ * acceptance) and records `collected`, and only then has it ended. */
33
+ readonly collect?: true;
30
34
  }
35
+ /** 0.49.0 B: how a writer's collection ended. */
36
+ export type CollectionOutcome = "collected" | "unchanged" | "stopped" | "failed";
31
37
  export type TaskRecord = {
32
38
  readonly type: "planned";
33
39
  readonly ts: number;
@@ -89,6 +95,14 @@ export type TaskRecord = {
89
95
  readonly ts: number;
90
96
  readonly transition: ClaimedTransition;
91
97
  readonly executionId: string;
98
+ }
99
+ /** 0.49.0 B: the parent's collection of a writer finished — the LAST
100
+ * record of its end; until it is written the writer has not ended. */
101
+ | {
102
+ readonly type: "collected";
103
+ readonly ts: number;
104
+ readonly outcome: CollectionOutcome;
105
+ readonly reason?: string;
92
106
  };
93
107
  /** A transition as the model is told it — a notice's or a result's name. */
94
108
  export type ClaimedTransition = "ready" | "exited" | "failed" | "stopped" | "unknown";
@@ -21,7 +21,7 @@
21
21
  * an idle session wakes (3c).
22
22
  */
23
23
  import type { AbortSignalLike } from "@vincemakes/kiso-core";
24
- import { type ClaimedTransition, type TaskAgent, type TaskProfile, type TaskState } from "./journal.js";
24
+ import { type ClaimedTransition, type CollectionOutcome, type TaskAgent, type TaskProfile, type TaskState } from "./journal.js";
25
25
  /** Whether a recorded runner is still that process (ADR-0058 §6):
26
26
  * "verified" — the pid is live AND its OS start time is the recorded one;
27
27
  * "gone" — no such process, or another process holds the pid;
@@ -86,7 +86,15 @@ export interface TaskInfo {
86
86
  readonly transition: ClaimedTransition;
87
87
  readonly executionId: string;
88
88
  }[];
89
+ /** 0.49.0 B: a writer's collection, once recorded (absent until then). */
90
+ readonly collection?: {
91
+ readonly outcome: CollectionOutcome;
92
+ readonly reason?: string;
93
+ };
89
94
  }
95
+ /** 0.49.0 B: a writer whose process ended and whose collection is not yet
96
+ * recorded — not ended: never announced, never settled, never complete. */
97
+ export declare function isCollecting(task: TaskInfo): boolean;
90
98
  /** The name a task's end (or its runner's vanishing, or a start that never
91
99
  * ran) has for the model — as a notice says it, and as a claim records it. */
92
100
  export declare function endTransitionOf(task: TaskInfo): Exclude<ClaimedTransition, "ready"> | null;
@@ -144,6 +152,12 @@ export interface TaskManagerOptions {
144
152
  readonly backend: TaskBackend;
145
153
  /** A first listener — the same as `subscribe` right after construction. */
146
154
  readonly onTransition?: TaskListener;
155
+ /** 0.49.0 B: the host's collector for a writer that ended — called once
156
+ * per collecting task (by the watcher, a wait, or `collectPending`). It
157
+ * records `collected` itself, last; a collector that throws leaves the
158
+ * record to the manager (`failed`, with the reason), so a writer's end
159
+ * is never stuck. */
160
+ readonly collect?: (task: TaskInfo) => Promise<void>;
147
161
  /** How often live tasks are re-read. Default 500 ms. */
148
162
  readonly pollMs?: number;
149
163
  /** How long a live runner's identity is reused before it is checked
@@ -185,10 +199,16 @@ export declare class TaskManager {
185
199
  * durable first, then the transition counts as seen, so it is never
186
200
  * announced. A timeout, an abort, no execution id, an agent task (its
187
201
  * end belongs to its group, 3d) or a claim that cannot be written
188
- * claim nothing: the transition is announced as before. */
202
+ * claim nothing: the transition is announced as before.
203
+ *
204
+ * 0.49.0 A: `agentJoin` — the delegate call that started an agent task
205
+ * and waits for it (the join) claims its end, so its own result reports
206
+ * it and the group's notice names it as reported (I2: only a claim made
207
+ * durable counts). While it waits, the watcher never announces it (I1). */
189
208
  awaitSettled(id: string, until: "end" | "ready", ms: number, opts?: {
190
209
  readonly executionId?: string;
191
210
  readonly signal?: AbortSignalLike;
211
+ readonly agentJoin?: boolean;
192
212
  }): Promise<TaskSettled>;
193
213
  /** 3e: a running foreground command that can become a task now —
194
214
  * registered by its owner while it runs; returns the unregister. Every
@@ -216,4 +236,8 @@ export declare class TaskManager {
216
236
  observe(): void;
217
237
  /** Stop watching. Tasks keep running. */
218
238
  close(): void;
239
+ /** 0.49.0 B: collect every writer that ended uncollected — at a session's
240
+ * open, BEFORE its delivery is built, so a restart delivers a writer as
241
+ * it delivers anything that ended while nobody listened (never a wake). */
242
+ collectPending(): Promise<void>;
219
243
  }
@@ -23,10 +23,17 @@
23
23
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
25
  import { appendRecord, fsyncDir, readRecords, runnerOf, verdictOf } from "./journal.js";
26
+ /** 0.49.0 B: a writer whose process ended and whose collection is not yet
27
+ * recorded — not ended: never announced, never settled, never complete. */
28
+ export function isCollecting(task) {
29
+ return task.agent?.collect === true && task.state.kind === "ended" && task.collection === undefined;
30
+ }
26
31
  /** The name a task's end (or its runner's vanishing, or a start that never
27
32
  * ran) has for the model — as a notice says it, and as a claim records it. */
28
33
  export function endTransitionOf(task) {
29
34
  const s = task.state;
35
+ if (isCollecting(task))
36
+ return null;
30
37
  if (s.kind === "ended") {
31
38
  if (task.stoppedBy !== undefined)
32
39
  return "stopped";
@@ -58,9 +65,13 @@ export class TaskManager {
58
65
  #detachable = new Map();
59
66
  /** id → how many `awaitSettled` calls wait on it: the watcher skips it */
60
67
  #waiting = new Map();
68
+ /** 0.49.0 B: the host's collector, and the writers being collected now */
69
+ #collect;
70
+ #collecting = new Map();
61
71
  constructor(options) {
62
72
  this.root = options.root;
63
73
  this.#backend = options.backend;
74
+ this.#collect = options.collect;
64
75
  if (options.onTransition !== undefined)
65
76
  this.#listeners.add(options.onTransition);
66
77
  this.#pollMs = options.pollMs ?? 500;
@@ -234,7 +245,12 @@ export class TaskManager {
234
245
  * durable first, then the transition counts as seen, so it is never
235
246
  * announced. A timeout, an abort, no execution id, an agent task (its
236
247
  * end belongs to its group, 3d) or a claim that cannot be written
237
- * claim nothing: the transition is announced as before. */
248
+ * claim nothing: the transition is announced as before.
249
+ *
250
+ * 0.49.0 A: `agentJoin` — the delegate call that started an agent task
251
+ * and waits for it (the join) claims its end, so its own result reports
252
+ * it and the group's notice names it as reported (I2: only a claim made
253
+ * durable counts). While it waits, the watcher never announces it (I1). */
238
254
  async awaitSettled(id, until, ms, opts = {}) {
239
255
  if (this.get(id) === undefined)
240
256
  throw new Error(`no task ${id} in this session`);
@@ -250,9 +266,10 @@ export class TaskManager {
250
266
  const deadline = Date.now() + ms;
251
267
  for (;;) {
252
268
  const info = this.#info(id);
269
+ this.#maybeCollect(info); // a writer it waits for ends only once collected
253
270
  const reached = endTransitionOf(info) ?? (until === "ready" && info.state.kind === "running" && info.state.ready ? "ready" : null);
254
271
  if (reached !== null)
255
- return { info, settled: true, claimed: this.#claim(info, reached, opts.executionId) };
272
+ return { info, settled: true, claimed: this.#claim(info, reached, opts.executionId, opts.agentJoin === true) };
256
273
  const left = deadline - Date.now();
257
274
  if (aborted || left <= 0)
258
275
  return { info, settled: false, claimed: false };
@@ -277,8 +294,8 @@ export class TaskManager {
277
294
  /** The frozen order: the claim durable, THEN seen — an append that fails
278
295
  * leaves the transition unseen, to be announced (a duplicate is the
279
296
  * lesser evil next to a lost transition). */
280
- #claim(info, transition, executionId) {
281
- if (executionId === undefined || info.agent !== undefined)
297
+ #claim(info, transition, executionId, agentJoin = false) {
298
+ if (executionId === undefined || (info.agent !== undefined && !agentJoin))
282
299
  return false;
283
300
  try {
284
301
  appendRecord(join(this.root, info.id, JOURNAL), { type: "result_claimed", ts: Date.now(), transition, executionId });
@@ -378,9 +395,19 @@ export class TaskManager {
378
395
  }
379
396
  #poll() {
380
397
  for (const id of this.#ids()) {
381
- // a tool call is waiting on it: what it sees, its result reports
382
- if (this.#waiting.has(id))
398
+ // a tool call is waiting on it: what it sees, its result reports —
399
+ // but a writer it waits for is still collected (0.49.0 B)
400
+ if (this.#waiting.has(id)) {
401
+ if (this.#collect !== undefined) {
402
+ try {
403
+ this.#maybeCollect(this.#info(id));
404
+ }
405
+ catch {
406
+ // a corrupt journal: read loudly where it is read on purpose
407
+ }
408
+ }
383
409
  continue;
410
+ }
384
411
  const before = this.#seen.get(id);
385
412
  // a lost (`unknown`) task is still read: if its end is recorded
386
413
  // later, that end is announced (Amendment 8)
@@ -393,6 +420,7 @@ export class TaskManager {
393
420
  catch {
394
421
  continue; // a corrupt journal: reported where it is read, never here in the background
395
422
  }
423
+ this.#maybeCollect(info);
396
424
  const now = this.#kindOf(info);
397
425
  if (now === before)
398
426
  continue;
@@ -404,8 +432,52 @@ export class TaskManager {
404
432
  }
405
433
  }
406
434
  #kindOf(info) {
435
+ if (isCollecting(info))
436
+ return "collecting";
407
437
  return info.state.kind === "running" && info.state.ready ? "ready" : info.state.kind;
408
438
  }
439
+ /** 0.49.0 B: start a writer's collection once — never twice at a time. A
440
+ * collector that throws (or ends without its record) is recorded as a
441
+ * failed collection, so the writer's end is never stuck. */
442
+ #maybeCollect(info) {
443
+ if (this.#collect === undefined || !isCollecting(info) || this.#collecting.has(info.id))
444
+ return;
445
+ const collect = this.#collect;
446
+ const journal = join(this.root, info.id, JOURNAL);
447
+ const done = (async () => {
448
+ let why;
449
+ try {
450
+ await collect(info);
451
+ }
452
+ catch (err) {
453
+ why = err instanceof Error ? err.message : String(err);
454
+ }
455
+ if (!readRecords(journal).some((r) => r.type === "collected")) {
456
+ appendRecord(journal, { type: "collected", ts: Date.now(), outcome: "failed", reason: why ?? "the collector recorded nothing" });
457
+ }
458
+ })().finally(() => this.#collecting.delete(info.id));
459
+ this.#collecting.set(info.id, done);
460
+ }
461
+ /** 0.49.0 B: collect every writer that ended uncollected — at a session's
462
+ * open, BEFORE its delivery is built, so a restart delivers a writer as
463
+ * it delivers anything that ended while nobody listened (never a wake). */
464
+ async collectPending() {
465
+ for (const id of this.#ids()) {
466
+ let info;
467
+ try {
468
+ info = this.#info(id);
469
+ }
470
+ catch {
471
+ continue;
472
+ }
473
+ if (info === undefined)
474
+ continue;
475
+ this.#maybeCollect(info);
476
+ const pending = this.#collecting.get(id);
477
+ if (pending !== undefined)
478
+ await pending;
479
+ }
480
+ }
409
481
  #info(id) {
410
482
  const records = readRecords(join(this.root, id, JOURNAL));
411
483
  const planned = records.find((r) => r.type === "planned");
@@ -419,6 +491,7 @@ export class TaskManager {
419
491
  const claims = records
420
492
  .filter((r) => r.type === "result_claimed")
421
493
  .map((r) => ({ transition: r.transition, executionId: r.executionId }));
494
+ const collected = records.find((r) => r.type === "collected");
422
495
  return {
423
496
  id,
424
497
  command: planned?.command ?? "",
@@ -432,6 +505,7 @@ export class TaskManager {
432
505
  startedAt: planned?.ts ?? 0,
433
506
  ...(terminal !== undefined ? { endedAt: terminal.ts } : {}),
434
507
  ...(claims.length > 0 ? { claims } : {}),
508
+ ...(collected !== undefined ? { collection: { outcome: collected.outcome, ...(collected.reason !== undefined ? { reason: collected.reason } : {}) } } : {}),
435
509
  };
436
510
  }
437
511
  #ids() {
@@ -176,6 +176,7 @@ export class RequestTracer {
176
176
  const rent = buildRentLedger({
177
177
  model: options.model,
178
178
  ...(rentParts?.base !== undefined ? { base: rentParts.base } : {}),
179
+ ...(rentParts?.table !== undefined ? { table: rentParts.table } : {}),
179
180
  ...(rentParts?.appends !== undefined ? { appends: rentParts.appends } : {}),
180
181
  ...(tools !== undefined ? { tools } : {}),
181
182
  });
@@ -11,10 +11,17 @@
11
11
  *
12
12
  * - system:base the session's own prompt as the CLI handed it
13
13
  * (built-in constant + project instructions — the
14
- * runtime cannot split those, R3). The runtime's
15
- * generated tool table is machinery BETWEEN base and
16
- * appends; it stays out of the ledger (counts live
17
- * in tool:<name> lines and the base's own line).
14
+ * runtime cannot split those, R3).
15
+ * - system:tools the runtime's generated tool table (compose.ts,
16
+ * composeToolTable): the routing rows, the fixed
17
+ * directives, each tool's snippet and guidelines,
18
+ * between the base and the appends. OVERTURNS the
19
+ * E3 note that it "stays out of the ledger (counts
20
+ * live in tool:<name> lines)": the table is prose
21
+ * the tool specs do not contain (1,686 chars at
22
+ * f42a0edc), so the ledger was short by that much
23
+ * on every request (finding RG-F2, kiso-doc
24
+ * plan-rent-gate rev 2).
18
25
  * - system:ext:<name> one line per extension with a non-empty
19
26
  * systemPrompt.append, measured on the append
20
27
  * string, in load order.
@@ -44,7 +51,7 @@
44
51
  */
45
52
  import type { ToolSpec } from "@vincemakes/kiso-core";
46
53
  export interface RentLine {
47
- /** "system:base" | "system:ext:<name>" | "tool:<name>" | "envelope" */
54
+ /** "system:base" | "system:tools" | "system:ext:<name>" | "tool:<name>" | "envelope" */
48
55
  surface: string;
49
56
  /** length of the serialized surface, measured, never a copy */
50
57
  chars: number;
@@ -59,6 +66,9 @@ export declare const RENT_LINE_FIELDS: readonly ["surface", "chars", "estTokens"
59
66
  * systemPrompt is the result — the parts are the ledger's inputs). */
60
67
  export interface RentParts {
61
68
  base?: string;
69
+ /** RG-F2: the generated tool table's text, as composed for the run
70
+ * (absent when the table is off or the registry is empty). */
71
+ table?: string;
62
72
  appends?: readonly {
63
73
  name: string;
64
74
  text: string;
@@ -68,7 +78,7 @@ export interface RentInput extends RentParts {
68
78
  model: string;
69
79
  tools?: readonly ToolSpec[];
70
80
  }
71
- /** The one ledger: system:base, then system:ext:* (load order), then
81
+ /** The one ledger: system:base, then system:tools, then system:ext:* (load order), then
72
82
  * tool:* (the array's order — the registry's toSpecs() order), then the
73
83
  * envelope. Absent surfaces contribute no lines (R9). */
74
84
  export declare function buildRentLedger(input: RentInput): RentLine[];
@@ -11,10 +11,17 @@
11
11
  *
12
12
  * - system:base the session's own prompt as the CLI handed it
13
13
  * (built-in constant + project instructions — the
14
- * runtime cannot split those, R3). The runtime's
15
- * generated tool table is machinery BETWEEN base and
16
- * appends; it stays out of the ledger (counts live
17
- * in tool:<name> lines and the base's own line).
14
+ * runtime cannot split those, R3).
15
+ * - system:tools the runtime's generated tool table (compose.ts,
16
+ * composeToolTable): the routing rows, the fixed
17
+ * directives, each tool's snippet and guidelines,
18
+ * between the base and the appends. OVERTURNS the
19
+ * E3 note that it "stays out of the ledger (counts
20
+ * live in tool:<name> lines)": the table is prose
21
+ * the tool specs do not contain (1,686 chars at
22
+ * f42a0edc), so the ledger was short by that much
23
+ * on every request (finding RG-F2, kiso-doc
24
+ * plan-rent-gate rev 2).
18
25
  * - system:ext:<name> one line per extension with a non-empty
19
26
  * systemPrompt.append, measured on the append
20
27
  * string, in load order.
@@ -49,13 +56,15 @@ const line = (surface, text) => ({
49
56
  chars: text.length,
50
57
  estTokens: Math.ceil(text.length / 4),
51
58
  });
52
- /** The one ledger: system:base, then system:ext:* (load order), then
59
+ /** The one ledger: system:base, then system:tools, then system:ext:* (load order), then
53
60
  * tool:* (the array's order — the registry's toSpecs() order), then the
54
61
  * envelope. Absent surfaces contribute no lines (R9). */
55
62
  export function buildRentLedger(input) {
56
63
  const lines = [];
57
64
  if (input.base !== undefined)
58
65
  lines.push(line("system:base", input.base));
66
+ if (input.table !== undefined && input.table !== "")
67
+ lines.push(line("system:tools", input.table));
59
68
  for (const append of input.appends ?? [])
60
69
  lines.push(line(`system:ext:${append.name}`, append.text));
61
70
  for (const tool of input.tools ?? []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.47.3",
3
+ "version": "0.49.0",
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.47.3"
28
+ "@vincemakes/kiso-core": "0.49.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.47.3",
32
- "@vincemakes/kiso-provider-openai": "0.47.3",
33
- "@vincemakes/kiso-provider-openai-responses": "0.47.3"
31
+ "@vincemakes/kiso-provider-anthropic": "0.49.0",
32
+ "@vincemakes/kiso-provider-openai": "0.49.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.49.0"
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.47.3",
47
+ "@vincemakes/kiso-evals": "0.49.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"