@vincemakes/kiso-runtime 0.48.0 → 0.50.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");
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" | "maxWakes" | "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
@@ -37,6 +37,15 @@
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";
@@ -64,6 +73,16 @@ export interface TaskDeliveryOptions {
64
73
  * startup or as it is heard — the person can be told at once, while
65
74
  * the model's notice keeps its own turn. A claimed loss never comes. */
66
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;
67
86
  /** How much of a failed task's output rides its notice. Default 2 KB. */
68
87
  readonly tailBytes?: number;
69
88
  /** Read a task's output tail (injectable for tests). */
@@ -37,17 +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
57
  export const DEFAULT_MAX_WAKES = 20;
48
- /** ADR-0058 3d (D3): a child's excerpt, and a group's excerpts together. */
49
- const CHILD_EXCERPT_BYTES = 4_096;
50
- const GROUP_EXCERPT_BYTES = 16_384;
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;
51
64
  export class TaskDelivery {
52
65
  #o;
53
66
  #receipts = new Set();
@@ -62,6 +75,8 @@ export class TaskDelivery {
62
75
  /** ended agent tasks waiting for their group (restart: heard at startup) */
63
76
  #ended = new Map();
64
77
  #recheck = null;
78
+ /** the person-run the budget was last said spent in (0480-F9) */
79
+ #spentEpoch = null;
65
80
  #unsubscribe;
66
81
  constructor(options) {
67
82
  this.#o = options;
@@ -75,8 +90,10 @@ export class TaskDelivery {
75
90
  const item = itemOf(task);
76
91
  if (item === null || item.transition === "ready" || this.#receipts.has(key(item)))
77
92
  continue;
78
- // a tool result reported it: the claim alone decides (Amendment 7)
79
- 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)))
80
97
  continue;
81
98
  if (item.transition === "unknown")
82
99
  lost.push(task.id);
@@ -167,8 +184,8 @@ export class TaskDelivery {
167
184
  const mode = this.#modeOf(task, item);
168
185
  if (mode === "silent")
169
186
  return;
170
- const spent = mode === "notify" && this.#eligible(task, item) && !this.#underBudget();
171
- this.#buffer.push({ item, line: spent ? `${this.#line(task, item)}\nchain budget spent: ${this.#wakesSinceLastPerson()} autonomous wakes since the person's last message — continue when they speak` : 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 });
172
189
  if (this.#timer === null)
173
190
  this.#timer = setTimeout(() => this.#flush(), this.#o.windowMs ?? 1_000);
174
191
  }
@@ -259,6 +276,43 @@ export class TaskDelivery {
259
276
  #underBudget() {
260
277
  return this.#wakesSinceLastPerson() < (this.#o.maxWakes ?? DEFAULT_MAX_WAKES);
261
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
+ }
262
316
  #wakesSinceLastPerson() {
263
317
  let wakes = 0;
264
318
  let firstOfRun = true;
@@ -348,9 +402,11 @@ export class TaskDelivery {
348
402
  return { closed: to < events.length && (runEnded || [...calls].every((c) => answered.has(c))), members: members.includes(task.id) ? members : [...members, task.id] };
349
403
  }
350
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
351
407
  return group.closed && group.members.every((id) => {
352
- const kind = this.#o.manager.get(id)?.state.kind;
353
- 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;
354
410
  });
355
411
  }
356
412
  /** ONE notice for a complete group: every member's line, excerpts within
@@ -362,10 +418,19 @@ export class TaskDelivery {
362
418
  const tasks = members.map((id) => this.#o.manager.get(id)).filter((t) => t !== undefined);
363
419
  const fresh = [];
364
420
  const lines = [];
421
+ const unreturned = new Set();
365
422
  for (const task of tasks) {
366
423
  const item = itemOf(task);
367
424
  if (item === null)
368
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);
369
434
  if (this.#receipts.has(key(item)) || this.#inFlight.has(key(item))) {
370
435
  lines.push(`<kiso-task id="${task.id}" kind="agent" role="${task.agent?.role ?? ""}" status="${item.transition}" reported="earlier"/>`);
371
436
  continue;
@@ -379,11 +444,16 @@ export class TaskDelivery {
379
444
  for (const { task, item } of fresh) {
380
445
  const excerpt = excerptOf(task, Math.min(CHILD_EXCERPT_BYTES, budget));
381
446
  budget -= excerpt.bytes;
382
- lines.push(`${this.#agentTag(task, item)}${excerpt.text}`);
447
+ lines.push(`${this.#agentTag(task, item, unreturned.has(task.id))}${excerpt.text}`);
383
448
  }
384
449
  const items = fresh.map((f) => f.item);
385
450
  const notice = { lines, items };
386
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);
387
457
  const run = this.#o.liveRun();
388
458
  if (run !== undefined && this.#hand(run, notice, wake))
389
459
  return;
@@ -391,6 +461,12 @@ export class TaskDelivery {
391
461
  this.#pending.push({ notice, wake });
392
462
  this.#pump();
393
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
+ }
394
470
  /** handed to a run, or held for one, or already in the log */
395
471
  #known(k) {
396
472
  return this.#receipts.has(k) || this.#inFlight.has(k) || this.#pending.some((p) => p.notice.items.some((i) => key(i) === k));
@@ -400,8 +476,10 @@ export class TaskDelivery {
400
476
  #unpend(k) {
401
477
  this.#pending = this.#pending.filter((p) => !(p.notice.items.length === 1 && key(p.notice.items[0]) === k));
402
478
  }
403
- #agentTag(task, item) {
479
+ #agentTag(task, item, unreturned = false) {
404
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"`);
405
483
  const outcome = outcomeOf(task);
406
484
  if (outcome !== undefined)
407
485
  attrs.push(`outcome="${outcome}"`);
@@ -409,8 +487,9 @@ export class TaskDelivery {
409
487
  attrs.push(`session="${task.agent.session}"`);
410
488
  if (task.endedAt !== undefined)
411
489
  attrs.push(`duration="${duration(task.endedAt - task.startedAt)}"`);
490
+ // I5: the answer is named only once the record commits it
412
491
  const result = resultPath(task);
413
- attrs.push(existsSync(result) ? `result="${result}"` : `output="${task.outputPath}"`);
492
+ attrs.push(recordOf(task) !== null && existsSync(result) ? `result="${result}"` : `output="${task.outputPath}"`);
414
493
  return `<kiso-task ${attrs.join(" ")}/>`;
415
494
  }
416
495
  #line(task, item) {
@@ -447,42 +526,62 @@ function itemOf(task, transition) {
447
526
  return end === null ? null : { taskId: task.id, transition: end };
448
527
  }
449
528
  const resultPath = (task) => join(dirname(task.outputPath), "result.md");
450
- /** The child's outcome, as it wrote it beside its answer. */
529
+ /** The child's outcome, as its result record commits it. */
451
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;
452
536
  try {
453
- const parsed = JSON.parse(readFileSync(join(dirname(task.outputPath), "result.json"), "utf8"));
454
- return typeof parsed.outcome === "string" ? parsed.outcome : undefined;
537
+ raw = readFileSync(join(dirname(task.outputPath), "result.json"), "utf8");
455
538
  }
456
539
  catch {
457
- return undefined;
540
+ raw = null;
458
541
  }
542
+ return handoffRecordOf(raw);
459
543
  }
460
- /** A child's answer (result.md) cut to `bytes`, or — with no answer — the
461
- * 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. */
462
548
  function excerptOf(task, bytes) {
463
- const path = resultPath(task);
464
- let answer;
465
- try {
466
- 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
+ }
467
558
  }
468
- catch {
469
- const tail = bytes > 0 ? readTail(task, Math.min(bytes, 2_048)) : "";
470
- return tail === "" ? { text: "", bytes: 0 } : { text: `\n${tail}`, bytes: Buffer.byteLength(tail) };
471
- }
472
- if (answer === "")
473
- return { text: "", bytes: 0 };
474
- const size = Buffer.byteLength(answer);
475
- if (size <= bytes)
476
- return { text: `\n${answer}`, bytes: size };
477
- if (bytes <= 0)
478
- return { text: `\n[the whole answer: ${path}]`, bytes: 0 };
479
- const cut = Buffer.from(answer).subarray(0, bytes).toString("utf8").replace(/\uFFFD+$/, "");
480
- 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);
481
561
  }
482
562
  function duration(ms) {
483
563
  const s = Math.max(0, Math.round(ms / 1000));
484
564
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
485
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
+ }
486
585
  function readTail(task, bytes) {
487
586
  try {
488
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.48.0",
3
+ "version": "0.50.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.48.0"
28
+ "@vincemakes/kiso-core": "0.50.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.48.0",
32
- "@vincemakes/kiso-provider-openai": "0.48.0",
33
- "@vincemakes/kiso-provider-openai-responses": "0.48.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.50.0",
32
+ "@vincemakes/kiso-provider-openai": "0.50.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.50.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.48.0",
47
+ "@vincemakes/kiso-evals": "0.50.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"