@yaag/runtime 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.
@@ -1,6 +1,12 @@
1
1
  import { REPORT_RESULT_EXTENSION_PATH, REPORT_RESULT_TOOL_NAME } from "../ask-contract/index.ts";
2
2
  import { SYSTEM_PROMPT_RECORDER_EXTENSION_PATH } from "./system-prompt-recorder.ts";
3
- import type { AgentStats, Frame, OpenOptions, TokenBreakdown } from "./transport.ts";
3
+ import type {
4
+ AgentStats,
5
+ CompactionResult,
6
+ Frame,
7
+ OpenOptions,
8
+ TokenBreakdown,
9
+ } from "./transport.ts";
4
10
 
5
11
  /** What the Agent reported about its own work, read from a `get_state` probe. */
6
12
  export interface AgentProgress {
@@ -23,7 +29,10 @@ export function piCommand(options: OpenOptions, toolProbeExtensionPath?: string)
23
29
  }
24
30
  appendCapabilities(cmd, options, toolProbeExtensionPath);
25
31
  if (options.sessionDir) cmd.push("--session-dir", options.sessionDir);
32
+ // `--session` and `--fork` are mutually exclusive in pi, and a resume is the
33
+ // stronger claim: it names the very session this Agent must continue.
26
34
  if (options.sessionFile) cmd.push("--session", options.sessionFile);
35
+ else if (options.forkSession) cmd.push("--fork", options.forkSession);
27
36
  // No --approve: Agents inherit the user's saved project-trust decision (ADR-0009).
28
37
  return cmd;
29
38
  }
@@ -154,6 +163,47 @@ export function readStats(response: Frame): AgentStats {
154
163
  return { tokens: readBreakdown("tokens" in data ? data.tokens : null), cost };
155
164
  }
156
165
 
166
+ /**
167
+ * What a `compact` response reports (ADR-0043).
168
+ *
169
+ * A missing field becomes null rather than 0, so "unknown" and "free" stay
170
+ * distinguishable: a custom compaction handler may answer without usage.
171
+ */
172
+ export function readCompaction(response: Frame): CompactionResult {
173
+ const data: unknown = response.data;
174
+ if (typeof data !== "object" || data === null) {
175
+ return { tokensBefore: null, tokensAfter: null, tokens: null, cost: null };
176
+ }
177
+ const record: Record<string, unknown> = { ...data };
178
+ const usage: unknown = record.usage;
179
+ const usageRecord: Record<string, unknown> =
180
+ typeof usage === "object" && usage !== null ? { ...usage } : {};
181
+ const cost: unknown = usageRecord.cost;
182
+ return {
183
+ tokensBefore: typeof record.tokensBefore === "number" ? record.tokensBefore : null,
184
+ tokensAfter:
185
+ typeof record.estimatedTokensAfter === "number" ? record.estimatedTokensAfter : null,
186
+ tokens: readCompactionTokens(usageRecord),
187
+ cost:
188
+ typeof cost === "object" &&
189
+ cost !== null &&
190
+ typeof (cost as { total?: unknown }).total === "number"
191
+ ? (cost as { total: number }).total
192
+ : null,
193
+ };
194
+ }
195
+
196
+ /** The summary call's own token breakdown, or null when pi reported none. */
197
+ function readCompactionTokens(usage: Record<string, unknown>): TokenBreakdown | null {
198
+ return readBreakdown({
199
+ input: usage.input,
200
+ output: usage.output,
201
+ cacheRead: usage.cacheRead,
202
+ cacheWrite: usage.cacheWrite,
203
+ total: usage.totalTokens,
204
+ });
205
+ }
206
+
157
207
  function readBreakdown(tokens: unknown): TokenBreakdown | null {
158
208
  if (typeof tokens !== "object" || tokens === null) return null;
159
209
  const record: Record<string, unknown> = { ...tokens };
@@ -31,6 +31,23 @@ export interface AgentStats {
31
31
  readonly cost: number | null;
32
32
  }
33
33
 
34
+ /**
35
+ * What one compaction of an Agent's context reported (ADR-0043).
36
+ *
37
+ * Every field is null when pi reported nothing for it: a custom compaction
38
+ * handler may answer without usage, and "unknown" must stay distinct from 0.
39
+ */
40
+ export interface CompactionResult {
41
+ /** Context tokens before the summary replaced the transcript. */
42
+ readonly tokensBefore: number | null;
43
+ /** Estimated context tokens after the summary replaced the transcript. */
44
+ readonly tokensAfter: number | null;
45
+ /** Token breakdown of the summary call itself. */
46
+ readonly tokens: TokenBreakdown | null;
47
+ /** Cost of the summary call itself. */
48
+ readonly cost: number | null;
49
+ }
50
+
34
51
  /** Recorded inputs used to explain an Ask-hash mismatch without changing identity. */
35
52
  export interface AskMarkerContext {
36
53
  readonly prompt: string;
@@ -93,6 +110,27 @@ export interface AskCompletion {
93
110
  readonly recovered?: true;
94
111
  }
95
112
 
113
+ /**
114
+ * Opaque marker naming one compaction of an Agent's context.
115
+ *
116
+ * A compaction sits between two Asks, so it cannot ride the Agent-level frame
117
+ * streams: a Cassette replay switches those cursors to an Ask and never
118
+ * switches back. The marker gives the exchange its own recorded streams.
119
+ */
120
+ export interface CompactionMarker {
121
+ /** Monotonic per Agent, from 0. */
122
+ readonly index: number;
123
+ /** sha256 of the settled-Ask count and the custom instructions, when given. */
124
+ readonly hash: string;
125
+ /** Settled Asks of this Agent at the compaction point. */
126
+ readonly afterAsks: number;
127
+ }
128
+
129
+ /** Presence identifies Cassette playback of one compaction. */
130
+ export interface CompactionPlayback {
131
+ readonly result: CompactionResult;
132
+ }
133
+
96
134
  /** Presence identifies Cassette playback, including recorded successful Asks. */
97
135
  export interface AskPlayback {
98
136
  /** Limit outcome recorded for this Ask, if it rejected with ASK_LIMIT. */
@@ -143,6 +181,15 @@ export interface AgentTransport {
143
181
  /** Reports surfaced live outcomes; replay ignores completion. */
144
182
  finishAsk(completion: AskCompletion): void;
145
183
 
184
+ /**
185
+ * Begins one compaction. Live transports return undefined; replay transports
186
+ * return the recorded result.
187
+ */
188
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined;
189
+
190
+ /** Ends the compaction exchange, so later frames belong to the Agent again. */
191
+ finishCompaction(result: CompactionResult | undefined): void;
192
+
146
193
  /**
147
194
  * The extraction policy recorded for the Ask at `index`, when a Cassette
148
195
  * backs it. An Ask recorded under an older policy keeps that policy, so its
@@ -191,10 +238,31 @@ export interface OpenOptions {
191
238
  * Spawn identity only: it never becomes argv, and it is absent when empty.
192
239
  */
193
240
  readonly declaredExtensions?: readonly string[];
241
+ /**
242
+ * Resolved name of the Agent named as this Agent's parent (Parent Link).
243
+ * Spawn identity only: it never becomes argv.
244
+ */
245
+ readonly parent?: string;
194
246
  /** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
195
247
  readonly sessionDir?: string;
196
248
  /** Resumes an existing pi session, translated to `--session <path>`. */
197
249
  readonly sessionFile?: string;
250
+ /** How the Agent came to be; absent means an ordinary spawn. Spawn identity. */
251
+ readonly origin?: "fork";
252
+ /** Name of the Agent this one was forked from. Spawn identity; never argv. */
253
+ readonly forkOf?: string;
254
+ /** Settled Asks of the fork source at the fork point. Spawn identity. */
255
+ readonly forkAsks?: number;
256
+ /**
257
+ * The fork source's session file, translated to `pi --fork <path>`.
258
+ * Never spawn identity: the path is machine-specific.
259
+ */
260
+ readonly forkSession?: string;
261
+ /**
262
+ * The fork source's worktree, so the fork branches from it instead of from a
263
+ * clean base tree. Never spawn identity.
264
+ */
265
+ readonly worktreeFrom?: WorktreeResolution;
198
266
  }
199
267
 
200
268
  /** Nondeterministic worktree identity resolved below the transport seam. */
@@ -28,9 +28,17 @@ export function worktreeTransport(inner: TransportFactory): TransportFactory {
28
28
  observeStartup?: TransportStartupObserver,
29
29
  ): Promise<AgentTransport> {
30
30
  if (options.worktree !== true) return inner.open(options, observeStartup);
31
- const root = await repositoryRoot(options.cwd, options.name);
32
- await requireClean(root, options.name);
33
- const resolution = await addWorktree({ root, name: options.name });
31
+ const from = options.worktreeFrom;
32
+ const root = await repositoryRoot(from?.cwd ?? options.cwd, options.name);
33
+ // A fork branches from what its source already committed, so the source's
34
+ // dirty files stay behind and no clean check applies (ADR-0044).
35
+ if (from === undefined) await requireClean(root, options.name);
36
+ const resolution = await addWorktree({
37
+ root,
38
+ name: options.name,
39
+ namingRoot: await namingRoot(root),
40
+ startPoint: from?.branch ?? "HEAD",
41
+ });
34
42
  const { worktree: _worktree, ...innerOptions } = options;
35
43
  let reported = false;
36
44
  const transport = await inner.open(
@@ -69,18 +77,43 @@ async function requireClean(cwd: string, name: string): Promise<void> {
69
77
  }
70
78
  }
71
79
 
72
- async function addWorktree(options: { readonly root: string; readonly name: string }): Promise<{
80
+ /**
81
+ * The repository the worktree directory is named after.
82
+ *
83
+ * For a worktree it is the main repository, not the worktree itself, so a fork
84
+ * of a Worktree Agent does not nest `repo-worktrees/a1-worktrees/a2`.
85
+ */
86
+ async function namingRoot(root: string): Promise<string> {
87
+ const result = await git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
88
+ const common = result.stdout.trim();
89
+ if (result.code !== 0 || common === "" || !common.endsWith("/.git")) return root;
90
+ return common.slice(0, -"/.git".length);
91
+ }
92
+
93
+ async function addWorktree(options: {
94
+ readonly root: string;
95
+ readonly name: string;
96
+ readonly namingRoot: string;
97
+ readonly startPoint: string;
98
+ }): Promise<{
73
99
  readonly cwd: string;
74
100
  readonly branch: string;
75
101
  }> {
76
- const parent = join(dirname(options.root), `${basename(options.root)}-worktrees`);
102
+ const parent = join(dirname(options.namingRoot), `${basename(options.namingRoot)}-worktrees`);
77
103
  await mkdir(parent, { recursive: true });
78
104
  for (let suffix = 1; ; suffix += 1) {
79
105
  const leaf = suffix === 1 ? options.name : `${options.name}-${suffix}`;
80
106
  const branch = `yaag/${leaf}`;
81
107
  const cwd = join(parent, leaf);
82
108
  if (await candidateTaken(options.root, branch, cwd)) continue;
83
- const result = await git(options.root, ["worktree", "add", "-b", branch, cwd, "HEAD"]);
109
+ const result = await git(options.root, [
110
+ "worktree",
111
+ "add",
112
+ "-b",
113
+ branch,
114
+ cwd,
115
+ options.startPoint,
116
+ ]);
84
117
  if (result.code === 0) return { cwd, branch };
85
118
  if (isCollision(result)) continue;
86
119
  throw new Error(`git worktree add failed: ${result.stderr.trim() || result.stdout.trim()}`);
package/src/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ModelSpec, ThinkingLevel, ThinkingSpec } from "./model/index.ts";
3
+ import type { CompactionResult } from "./transport/index.ts";
3
4
 
4
5
  export type { ThinkingLevel } from "./model/index.ts";
6
+ export type { CompactionResult } from "./transport/index.ts";
5
7
 
6
8
  /** Options for spawning one Agent (ADR-0001, ADR-0009, ADR-0026). */
7
9
  export interface SpawnOptions {
@@ -66,6 +68,11 @@ export interface SpawnOptions {
66
68
  readonly name?: string;
67
69
  /** Request a fresh Git worktree. The requested cwd remains the base until spawn resolves. */
68
70
  readonly worktree?: boolean;
71
+ /**
72
+ * Names this Agent's parent in the Run tree (Parent Link). Data only: it is
73
+ * no conversation channel and no lifetime rule.
74
+ */
75
+ readonly parent?: Handle;
69
76
  }
70
77
 
71
78
  /**
@@ -73,12 +80,35 @@ export interface SpawnOptions {
73
80
  *
74
81
  * This is the settled shape, and it is what the Cassette identity hashes
75
82
  * (ADR-0039).
83
+ *
84
+ * `parent` is omitted on purpose: a Handle must never reach this shape, because
85
+ * an Ask records these options into the Cassette. Spawn resolves the Parent
86
+ * Link to a name, which travels in `OpenOptions` and stays out of Ask identity.
76
87
  */
77
- export interface ResolvedSpawnOptions extends Omit<SpawnOptions, "model" | "thinking"> {
88
+ export interface ResolvedSpawnOptions extends Omit<SpawnOptions, "model" | "thinking" | "parent"> {
78
89
  readonly model?: string;
79
90
  readonly thinking?: ThinkingLevel;
80
91
  }
81
92
 
93
+ /**
94
+ * What a fork may change about the Agent it copies (ADR-0044).
95
+ *
96
+ * A fork inherits the source's resolved spawn options verbatim: model,
97
+ * thinking, tools, skills, system prompt, extensions and Ask defaults. `name`
98
+ * is the exception — the child always gets a fresh auto-name unless this object
99
+ * names one. The fork source becomes the child's default `parent`.
100
+ *
101
+ * The inherited model is the concrete one the source settled on, so the child
102
+ * never re-runs the source's fallback list; `fork({ model })` is the way out.
103
+ * A fork of a Worktree Agent gets its own fresh worktree, branched from the
104
+ * source's branch, so only committed state transfers. The child re-runs the
105
+ * ADR-0026 Tool Contract probe against its own effective options.
106
+ */
107
+ export interface ForkOptions extends SpawnOptions {
108
+ /** true = pi's default compaction; a string = pi custom instructions. */
109
+ readonly compact?: boolean | string;
110
+ }
111
+
82
112
  /** Topology-only fields that may change when spawning an Agent Definition. */
83
113
  export interface SpawnOverrides {
84
114
  /** Log and event label. Defaults to the definition's name; duplicates are suffixed. */
@@ -87,6 +117,8 @@ export interface SpawnOverrides {
87
117
  readonly cwd?: string;
88
118
  /** Request a fresh Git worktree. */
89
119
  readonly worktree?: boolean;
120
+ /** Names this Agent's parent in the Run tree (Parent Link). Data only. */
121
+ readonly parent?: Handle;
90
122
  }
91
123
 
92
124
  /**
@@ -190,4 +222,25 @@ export interface Handle {
190
222
  options: StructuredAskOptions<Schema>,
191
223
  ): Promise<Static<Schema>>;
192
224
  ask(prompt: string, options?: AskOptions): Promise<string>;
225
+
226
+ /**
227
+ * Spawns a new Agent from a copy of this Agent's session (ADR-0044).
228
+ *
229
+ * The fork point is a settled Ask boundary: a call while an Ask is in flight
230
+ * rejects with `FORK_DURING_ASK`. An Agent that died mid-Ask, or that holds
231
+ * no session file, rejects with `FORK_REFUSED`. An Agent that exited cleanly
232
+ * stays forkable, so a program can build context, let the Agent exit, then
233
+ * fork its final state many times.
234
+ */
235
+ fork(overrides?: ForkOptions): Promise<Handle>;
236
+
237
+ /**
238
+ * Replaces this Agent's context with a summary of it (ADR-0043).
239
+ *
240
+ * `instructions` becomes pi's `customInstructions`. A call while an Ask is in
241
+ * flight rejects with `COMPACT_DURING_ASK`; a refusal from pi rejects with
242
+ * `COMPACT_FAILED`. The result reports what the summary call cost; each field
243
+ * is null when pi reported nothing for it.
244
+ */
245
+ compact(instructions?: string): Promise<CompactionResult>;
193
246
  }