@yaag/extension 0.9.0 → 0.11.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/docs/authoring.md CHANGED
@@ -113,3 +113,47 @@ rules. ADR-0022 plans the human-in-the-loop verbs; they are not available yet.
113
113
  program again and replays the recorded Asks. A resume needs the program too,
114
114
  because a Cassette holds the history of a Run and never the program. See
115
115
  [Examples](examples.md#05--record-and-resume).
116
+
117
+ ## Fork and compaction
118
+
119
+ `handle.compact()` replaces an Agent's context with a summary of it, and
120
+ reports the context size before and after. `handle.fork()` spawns a new Agent
121
+ from a copy of this Agent's conversation: use it when one expensive Agent
122
+ builds context that several cheap Agents need. Both need a settled Ask
123
+ boundary, so a call during an Ask fails with `COMPACT_DURING_ASK` or
124
+ `FORK_DURING_ASK`.
125
+
126
+ - An Agent that exited cleanly stays forkable: build context, close the Agent,
127
+ then fork its final state. An Agent killed during an Ask fails `FORK_REFUSED`.
128
+ - The fork inherits every spawn option of its source, including the concrete
129
+ model it settled on, but not its name. Pass any spawn option to change that.
130
+ The source becomes the fork's parent; pass `parent` to place it elsewhere.
131
+ - `fork({ compact: true })` compacts the copy before you get the Handle; a
132
+ string becomes the compaction instructions.
133
+ - A fork of a Worktree Agent gets its own worktree, branched from the source's
134
+ branch, so only committed work transfers. Forking needs a pi that supports
135
+ `pi --fork`. See [Examples](examples.md#07--fork-and-compaction).
136
+
137
+ ## Lineage
138
+
139
+ `parent` names the Agent a spawn belongs under. It is data only: it sets the
140
+ Agent's place in the Run tree, so the TUI and the Run Summary draw the child
141
+ under its parent. It opens no channel between the two Agents, and it ends no
142
+ lifetime — every Agent still dies with the Run.
143
+
144
+ `parent` takes a Handle a previous spawn in this Run returned, and it works the
145
+ same way as an override on a definition: `ctx.spawn(reviewer, { parent })`. A
146
+ parent that already exited stays a valid parent.
147
+
148
+ An Agent cannot spawn. It can *ask* for a helper through `outputSchema`, and
149
+ the program decides:
150
+
151
+ <!-- embed: docs/examples/06-lineage.ts -->
152
+
153
+ ```ts
154
+ for (const helper of wish.helpers) {
155
+ if (!ALLOWED_ROLES.has(helper.role)) continue;
156
+ const agent = await ctx.spawn({ name: helper.role, parent: implementer });
157
+ reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
158
+ }
159
+ ```
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Mediated autonomy. The implementer reports which helpers it wants; the
3
+ * program is the gate that decides, spawns them, and places them in the tree
4
+ * under the implementer with `parent`.
5
+ */
6
+ import { defineRun, prompt } from "@yaag/runtime";
7
+ import { Type } from "typebox";
8
+
9
+ const ALLOWED_ROLES = new Set(["reviewer", "summarizer"]);
10
+
11
+ const Wish = Type.Object({
12
+ report: Type.String(),
13
+ helpers: Type.Array(Type.Object({ role: Type.String(), reason: Type.String() })),
14
+ });
15
+
16
+ export default defineRun({
17
+ name: "lineage",
18
+ description: "Spawns the helpers an implementer asks for, under the implementer.",
19
+ async run(ctx) {
20
+ const implementer = await ctx.spawn({ name: "implementer" });
21
+ const wish = await implementer.ask(
22
+ prompt`Report one paragraph about this repository, and the helper roles you want.`,
23
+ { outputSchema: Wish },
24
+ );
25
+ const reports: string[] = [];
26
+ for (const helper of wish.helpers) {
27
+ if (!ALLOWED_ROLES.has(helper.role)) continue;
28
+ const agent = await ctx.spawn({ name: helper.role, parent: implementer });
29
+ reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
30
+ }
31
+ return [wish.report, ...reports].join("\n\n");
32
+ },
33
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Build context once, then work many times. The builder reads the repository;
3
+ * each worker forks the builder's conversation, so it starts with that context
4
+ * without paying to read the repository again.
5
+ */
6
+ import { defineRun, prompt } from "@yaag/runtime";
7
+
8
+ const TOPICS = ["error handling", "testing", "naming"] as const;
9
+
10
+ export default defineRun({
11
+ name: "fork",
12
+ description: "Forks one context-building Agent into several cheap workers.",
13
+ async run(ctx) {
14
+ const builder = await ctx.spawn({ name: "builder" });
15
+ await builder.ask(prompt`
16
+ Read this repository and describe how it is organized. Report one
17
+ paragraph.
18
+ `);
19
+ const notes = await Promise.all(
20
+ TOPICS.map(async (topic) => {
21
+ // The fork inherits the builder's options and its whole conversation.
22
+ // `compact` summarizes that conversation before the worker starts.
23
+ const worker = await builder.fork({ name: topic.split(" ")[0], compact: true });
24
+ return worker.ask(prompt`From what you already read, report on ${topic}.`);
25
+ }),
26
+ );
27
+ return notes.join("\n\n");
28
+ },
29
+ });
package/docs/examples.md CHANGED
@@ -100,3 +100,35 @@ holds it.
100
100
  Full file: [`examples/05-record-resume.ts`](examples/05-record-resume.ts). Run
101
101
  it with `yaag run examples/05-record-resume.ts --record run.json`, then resume
102
102
  it with `yaag run examples/05-record-resume.ts --resume run.json`.
103
+
104
+ ## 06 — lineage
105
+
106
+ `parent` places an Agent under another Agent in the Run tree. Here the
107
+ implementer reports the helper roles it wants, and the program decides which of
108
+ them to spawn. The helpers become a subtree of the implementer, and no Agent
109
+ ever spawns anything itself.
110
+
111
+ <!-- embed: docs/examples/06-lineage.ts -->
112
+
113
+ ```ts
114
+ const agent = await ctx.spawn({ name: helper.role, parent: implementer });
115
+ reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
116
+ ```
117
+
118
+ Full file: [`examples/06-lineage.ts`](examples/06-lineage.ts). Run it with
119
+ `yaag run examples/06-lineage.ts`.
120
+ ## 07 — fork and compaction
121
+
122
+ One Agent reads the repository. Each worker forks that Agent, so it inherits
123
+ the whole conversation instead of reading the repository again. `compact: true`
124
+ summarizes the inherited context before the worker starts.
125
+
126
+ <!-- embed: docs/examples/07-fork.ts -->
127
+
128
+ ```ts
129
+ const worker = await builder.fork({ name: topic.split(" ")[0], compact: true });
130
+ return worker.ask(prompt`From what you already read, report on ${topic}.`);
131
+ ```
132
+
133
+ Full file: [`examples/07-fork.ts`](examples/07-fork.ts). Run it with
134
+ `yaag run examples/07-fork.ts`.
@@ -103,6 +103,17 @@ Cause: `ASK_LIMIT` is a tripped soft budget (`maxTurns`, `maxToolCalls`,
103
103
  Fix: raise the budget, or add a `wrapUpPrompt`. After `ASK_LIMIT` the Handle is
104
104
  alive, so the program can ask again.
105
105
 
106
+ ## A fork or a compaction was refused
107
+
108
+ Cause: `COMPACT_DURING_ASK` and `FORK_DURING_ASK` mean an Ask of that Agent was
109
+ still in flight. Both operations need a settled Ask boundary. `FORK_REFUSED`
110
+ means the Agent cannot be forked at all: it was killed during an Ask, so the
111
+ end of its session is undefined, or it holds no session file, as a
112
+ Cassette-playback Agent does. `COMPACT_FAILED` means pi refused the summary.
113
+
114
+ Fix: await the Ask before you fork or compact. For `FORK_REFUSED`, fork an
115
+ Agent that settled its last Ask, or spawn a fresh Agent instead.
116
+
106
117
  ## describe executes the module top level
107
118
 
108
119
  <!-- quote: @yaag/cli/src/argv.ts -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/extension",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@earendil-works/pi-tui": "^0.84.0",
28
- "@yaag/cli": "0.9.0",
29
- "@yaag/runtime": "0.9.0",
30
- "@yaag/tui": "0.9.0",
28
+ "@yaag/cli": "0.11.0",
29
+ "@yaag/runtime": "0.11.0",
30
+ "@yaag/tui": "0.11.0",
31
31
  "nanoid": "^6.0.1"
32
32
  },
33
33
  "peerDependencies": {
@@ -18,13 +18,15 @@ export const DOC_PAGES = [
18
18
  "troubleshooting.md",
19
19
  ] as const;
20
20
 
21
- /** The five shipped example programs, in reading order. */
21
+ /** The shipped example programs, in reading order. */
22
22
  export const EXAMPLE_FILES = [
23
23
  "01-minimal.ts",
24
24
  "02-args.ts",
25
25
  "03-fan-out.ts",
26
26
  "04-controlled-ask.ts",
27
27
  "05-record-resume.ts",
28
+ "06-lineage.ts",
29
+ "07-fork.ts",
28
30
  ] as const;
29
31
 
30
32
  /** Absolute path of one shipped doc file, named relative to the docs root. */
@@ -2,6 +2,7 @@ import type {
2
2
  AgentActivity,
3
3
  AgentInfo,
4
4
  AgentState,
5
+ CompactionInfo,
5
6
  ModelFallbackInfo,
6
7
  NodeInfo,
7
8
  NodeState,
@@ -58,6 +59,7 @@ interface SummaryBase {
58
59
  readonly durationMs: number;
59
60
  readonly worstFrameGapMs: number;
60
61
  readonly modelFallbacks: number;
62
+ readonly compactions: number;
61
63
  }
62
64
 
63
65
  function parseBase(stored: Record<string, unknown>): SummaryBase | null {
@@ -93,6 +95,7 @@ function parseBase(stored: Record<string, unknown>): SummaryBase | null {
93
95
  // Tolerant, unlike the strict fields above: a record written before the
94
96
  // fallback counter existed must still load instead of disappearing.
95
97
  modelFallbacks: typeof stored.modelFallbacks === "number" ? stored.modelFallbacks : 0,
98
+ compactions: typeof stored.compactions === "number" ? stored.compactions : 0,
96
99
  };
97
100
  }
98
101
 
@@ -150,9 +153,16 @@ function parseAgent(value: unknown): AgentInfo | null {
150
153
  nodes,
151
154
  finishedNodesPruned: stored.finishedNodesPruned,
152
155
  modelFallbacks: fallbacks,
156
+ // Lineage is tolerant for the same reason as the counters: a record written
157
+ // before the Parent Link carries neither field.
158
+ parent: isNullableString(stored.parent) ? stored.parent : null,
159
+ origin: stored.origin === "fork" ? ("fork" as const) : ("spawn" as const),
153
160
  // Tolerant for the same reason as the Run-level counter.
154
161
  modelFallbacksPruned:
155
162
  typeof stored.modelFallbacksPruned === "number" ? stored.modelFallbacksPruned : 0,
163
+ // Tolerant as well: a record written before compaction carries neither field.
164
+ compactions: typeof stored.compactions === "number" ? stored.compactions : 0,
165
+ lastCompaction: parseCompaction(stored.lastCompaction),
156
166
  };
157
167
  if (state === "asking") {
158
168
  if (typeof stored.askIndex !== "number") return null;
@@ -171,6 +181,26 @@ function parseAgent(value: unknown): AgentInfo | null {
171
181
  return { ...base, state, askIndex: stored.askIndex, promptGist: stored.promptGist };
172
182
  }
173
183
 
184
+ /** The newest compaction of one Agent, or null when the record holds none. */
185
+ function parseCompaction(value: unknown): CompactionInfo | null {
186
+ const stored = asRecord(value);
187
+ if (stored === null) return null;
188
+ if (
189
+ !isNullableNumber(stored.tokensBefore) ||
190
+ !isNullableNumber(stored.tokensAfter) ||
191
+ !isNullableNumber(stored.cost) ||
192
+ !isNullableNumber(stored.at)
193
+ ) {
194
+ return null;
195
+ }
196
+ return {
197
+ tokensBefore: stored.tokensBefore,
198
+ tokensAfter: stored.tokensAfter,
199
+ cost: stored.cost,
200
+ at: stored.at,
201
+ };
202
+ }
203
+
174
204
  function parseNodes(value: unknown): NodeInfo[] | null {
175
205
  if (!Array.isArray(value)) return null;
176
206
  const nodes: NodeInfo[] = [];
@@ -82,9 +82,19 @@ function normalizeAgents(value: RunSummary): RunSummary {
82
82
  finishedNodesPruned: agent.finishedNodesPruned ?? 0,
83
83
  modelFallbacks: agent.modelFallbacks ?? [],
84
84
  modelFallbacksPruned: agent.modelFallbacksPruned ?? 0,
85
+ parent: agent.parent ?? null,
86
+ origin: normalizeOrigin(agent.origin),
87
+ // Absent in every message written before compaction shipped.
88
+ compactions: agent.compactions ?? 0,
89
+ lastCompaction: agent.lastCompaction ?? null,
85
90
  };
86
91
  }
87
- return { ...value, agents, modelFallbacks: value.modelFallbacks ?? 0 };
92
+ return {
93
+ ...value,
94
+ agents,
95
+ modelFallbacks: value.modelFallbacks ?? 0,
96
+ compactions: value.compactions ?? 0,
97
+ };
88
98
  }
89
99
 
90
100
  function optionalNodes(value: unknown): boolean {
@@ -166,10 +176,23 @@ function isAgentBase(value: Record<string, unknown>): boolean {
166
176
  optionalNodes(value.nodes) &&
167
177
  (value.finishedNodesPruned === undefined || natural(value.finishedNodesPruned)) &&
168
178
  optionalFallbacks(value.modelFallbacks) &&
169
- (value.modelFallbacksPruned === undefined || natural(value.modelFallbacksPruned))
179
+ (value.modelFallbacksPruned === undefined || natural(value.modelFallbacksPruned)) &&
180
+ // Lineage is tolerated as absent: a details blob persisted before the
181
+ // Parent Link carries neither field.
182
+ (value.parent === undefined || nullableString(value.parent)) &&
183
+ (value.origin === undefined || typeof value.origin === "string")
170
184
  );
171
185
  }
172
186
 
187
+ /**
188
+ * An origin a newer CLI may not have shipped yet reads as "spawn", the same way
189
+ * a stored Run record treats it. A value outside the contract must never drop
190
+ * the whole details blob and blank the Run view.
191
+ */
192
+ function normalizeOrigin(value: unknown): "spawn" | "fork" {
193
+ return value === "fork" ? "fork" : "spawn";
194
+ }
195
+
173
196
  function optionalEvent(value: unknown): value is LifecycleEvent | undefined {
174
197
  return value === undefined || isEvent(value);
175
198
  }
@@ -183,7 +206,9 @@ function isEvent(value: unknown): value is LifecycleEvent {
183
206
  return (
184
207
  strings(value.agent, value.model, value.cwd) &&
185
208
  optionalString(value.branch) &&
186
- optionalString(value.sessionFile)
209
+ optionalString(value.sessionFile) &&
210
+ optionalString(value.parent) &&
211
+ (value.origin === undefined || typeof value.origin === "string")
187
212
  );
188
213
  case "ask_start":
189
214
  return (