@yaag/runtime 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,21 @@
1
+ import type { Agent } from "./agent.ts";
2
+
3
+ /**
4
+ * Resolves a Parent Link to the parent Agent's name.
5
+ *
6
+ * Only a Handle this Run already spawned is accepted, which is what makes a
7
+ * lineage cycle impossible by construction: a Handle exists only after its own
8
+ * spawn resolved. Liveness is deliberately not checked — a cleanly exited Agent
9
+ * remains a valid parent, because the link states tree position, not a
10
+ * dependency on a live process.
11
+ */
12
+ export function resolveParent(parent: unknown, agents: readonly Agent[]): string | undefined {
13
+ if (parent === undefined) return undefined;
14
+ const known = agents.find((agent) => agent === parent);
15
+ if (known === undefined) {
16
+ throw new TypeError(
17
+ 'spawn option "parent" must be a Handle returned by ctx.spawn() in this Run',
18
+ );
19
+ }
20
+ return known.name;
21
+ }
@@ -9,6 +9,8 @@ export interface OpenRequestOptions {
9
9
  readonly spawnOptions: SpawnOptions;
10
10
  readonly resolvedExtensionPaths?: readonly string[];
11
11
  readonly declaredExtensions?: readonly string[];
12
+ /** Resolved parent Agent name (Parent Link); spawn identity only, never argv. */
13
+ readonly parent?: string;
12
14
  readonly sessionDir: string | undefined;
13
15
  }
14
16
 
@@ -51,6 +53,7 @@ export function openRequest(options: OpenRequestOptions): OpenOptions {
51
53
  ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
52
54
  ? {}
53
55
  : { declaredExtensions: options.declaredExtensions }),
56
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
54
57
  ...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
55
58
  ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
56
59
  };
@@ -61,7 +64,8 @@ export function withSelection(
61
64
  options: SpawnOptions,
62
65
  selection: ModelSelection,
63
66
  ): ResolvedSpawnOptions {
64
- const { model: _model, thinking: _thinking, ...rest } = options;
67
+ // `parent` is dropped here as well: a Handle must never reach recorded options.
68
+ const { model: _model, thinking: _thinking, parent: _parent, ...rest } = options;
65
69
  return {
66
70
  ...rest,
67
71
  ...(selection.model === undefined ? {} : { model: selection.model }),
@@ -22,6 +22,7 @@ import { Agent } from "./agent.ts";
22
22
  import { uniqueAgentName } from "./agent-names.ts";
23
23
  import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
24
24
  import { resolveSpawnExtensions } from "./spawn-extensions.ts";
25
+ import { resolveParent } from "./spawn-parent.ts";
25
26
  import { openRequest, withSelection } from "./spawn-request.ts";
26
27
 
27
28
  /** Dependencies for one Run's Agent-spawn gate. */
@@ -58,6 +59,8 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
58
59
  const request = resolveRequest(definitionOrOptions, overrides);
59
60
  const cwd = resolve(request.spawnOptions.cwd ?? process.cwd());
60
61
  const name = uniqueAgentName(request.spawnOptions.name, deps.agents.length, taken);
62
+ // Resolved before any transport work, so a bad Parent Link costs nothing.
63
+ const parent = resolveParent(request.parent, deps.agents);
61
64
  const resolution = normalizeModelResolution(request.spawnOptions);
62
65
  // One history per Agent: its spawn loop and every mid-Ask fallback loop
63
66
  // append to it, so a resolver sees every candidate that already failed.
@@ -94,6 +97,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
94
97
  cwd,
95
98
  spawnOptions: settled,
96
99
  ...extensionFields,
100
+ ...(parent === undefined ? {} : { parent }),
97
101
  sessionDir: deps.sessionDir,
98
102
  }),
99
103
  spawnOptions: settled,
@@ -107,6 +111,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
107
111
  cwd,
108
112
  spawnOptions: request.spawnOptions,
109
113
  ...extensionFields,
114
+ ...(parent === undefined ? {} : { parent }),
110
115
  sessionDir: deps.sessionDir,
111
116
  }),
112
117
  );
@@ -157,6 +162,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
157
162
  cwd: resolvedCwd,
158
163
  ...(branch === undefined ? {} : { branch }),
159
164
  ...(sessionFile === undefined ? {} : { sessionFile }),
165
+ ...(parent === undefined ? {} : { parent }),
160
166
  });
161
167
  return agent;
162
168
  } catch (error) {
@@ -178,10 +184,22 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
178
184
  };
179
185
  }
180
186
 
181
- type MutableSpawnOverrides = { -readonly [Key in keyof SpawnOverrides]: SpawnOverrides[Key] };
187
+ /** Topology overrides after validation; `parent` stays raw for `resolveParent`. */
188
+ type SpawnTopology = Omit<SpawnOverrides, "parent">;
189
+
190
+ type MutableSpawnOverrides = { -readonly [Key in keyof SpawnTopology]: SpawnTopology[Key] };
191
+
192
+ /** One validated override object: checked topology plus the unchecked Parent Link. */
193
+ interface ValidatedOverrides {
194
+ readonly topology: SpawnTopology;
195
+ readonly parent: unknown;
196
+ }
182
197
 
183
198
  interface SpawnRequest {
199
+ /** Never carries `parent`: a Handle must not reach the recorded options. */
184
200
  readonly spawnOptions: SpawnOptions;
201
+ /** The raw Parent Link option, validated by `resolveParent`. */
202
+ readonly parent: unknown;
185
203
  readonly askDefaults: AskOptions | undefined;
186
204
  /** Definition identity stays separate from a topology-overridden Agent name. */
187
205
  readonly definitionName: string | undefined;
@@ -192,9 +210,10 @@ function resolveRequest(
192
210
  overrides: SpawnOverrides | undefined,
193
211
  ): SpawnRequest {
194
212
  if (!isAgentDefinition(definitionOrOptions)) {
195
- return { spawnOptions: definitionOrOptions, askDefaults: undefined, definitionName: undefined };
213
+ const { parent, ...spawnOptions } = definitionOrOptions;
214
+ return { spawnOptions, parent, askDefaults: undefined, definitionName: undefined };
196
215
  }
197
- const topology = validateOverrides(overrides);
216
+ const { topology, parent } = validateOverrides(overrides);
198
217
  const config = agentDefinitionConfig(definitionOrOptions);
199
218
  return {
200
219
  spawnOptions: {
@@ -218,27 +237,32 @@ function resolveRequest(
218
237
  ? { systemPrompt: config.prompt }
219
238
  : { appendSystemPrompt: config.prompt }),
220
239
  },
240
+ parent,
221
241
  askDefaults: config.askDefaults,
222
242
  definitionName: config.name,
223
243
  };
224
244
  }
225
245
 
226
- function validateOverrides(overrides: unknown): SpawnOverrides {
227
- if (overrides === undefined) return {};
246
+ function validateOverrides(overrides: unknown): ValidatedOverrides {
247
+ if (overrides === undefined) return { topology: {}, parent: undefined };
228
248
  if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
229
249
  throw new TypeError(
230
250
  "spawn overrides must be an object: definitions own policy and spawn overrides own topology",
231
251
  );
232
252
  }
233
253
  for (const key of Object.keys(overrides)) {
234
- if (key !== "name" && key !== "cwd" && key !== "worktree") {
254
+ if (key !== "name" && key !== "cwd" && key !== "worktree" && key !== "parent") {
235
255
  throw new TypeError(
236
256
  `spawn override "${key}" is not allowed: definitions own policy and spawn overrides own topology`,
237
257
  );
238
258
  }
239
259
  }
240
260
  const topology: MutableSpawnOverrides = {};
261
+ // `parent` is never narrowed here: `resolveParent` owns that check, so the
262
+ // error text stays in one place and no unchecked value becomes a Handle.
263
+ let parent: unknown;
241
264
  for (const [key, value] of Object.entries(overrides)) {
265
+ if (key === "parent") parent = value;
242
266
  if (key === "name") {
243
267
  if (value !== undefined && typeof value !== "string") {
244
268
  throw new TypeError('spawn override "name" must be a string when present');
@@ -258,7 +282,7 @@ function validateOverrides(overrides: unknown): SpawnOverrides {
258
282
  topology.worktree = value;
259
283
  }
260
284
  }
261
- return topology;
285
+ return { topology, parent };
262
286
  }
263
287
 
264
288
  function isSpawnFailure(error: unknown): error is YaagError {
@@ -272,6 +296,7 @@ interface OpenTransportOptions {
272
296
  readonly spawnOptions: ResolvedSpawnOptions;
273
297
  readonly resolvedExtensionPaths?: readonly string[];
274
298
  readonly declaredExtensions?: readonly string[];
299
+ readonly parent?: string;
275
300
  readonly sessionDir: string | undefined;
276
301
  }
277
302
 
@@ -294,6 +319,7 @@ async function openTransport(options: OpenTransportOptions): Promise<OpenedTrans
294
319
  ...(options.declaredExtensions === undefined
295
320
  ? {}
296
321
  : { declaredExtensions: options.declaredExtensions }),
322
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
297
323
  sessionDir: options.sessionDir,
298
324
  }),
299
325
  (report) => Object.assign(startup, report),
@@ -38,6 +38,7 @@ const CassetteSpawnSchema = Type.Object({
38
38
  ...spawnPolicyFields,
39
39
  worktree: Type.Optional(Type.Literal(true)),
40
40
  declaredExtensions: Type.Optional(StringArray),
41
+ parent: Type.Optional(Type.String()),
41
42
  });
42
43
 
43
44
  const ContextSpawnSchema = Type.Object({
@@ -88,6 +88,11 @@ export interface CassetteSpawn {
88
88
  * across machines (ADR-0040). Absent when the Agent ran no extension.
89
89
  */
90
90
  readonly declaredExtensions?: readonly string[];
91
+ /**
92
+ * Resolved name of the Agent named as this Agent's parent (Parent Link).
93
+ * Part of spawn identity; absent for a root Agent and for older Cassettes.
94
+ */
95
+ readonly parent?: string;
91
96
  }
92
97
 
93
98
  /** The frames attributed to one Ask marker. */
@@ -272,6 +277,7 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
272
277
  ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
273
278
  ? {}
274
279
  : { declaredExtensions: [...options.declaredExtensions] }),
280
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
275
281
  ...(options.worktree === true ? { worktree: true } : {}),
276
282
  };
277
283
  }
@@ -145,7 +145,12 @@ const SPAWN_IDENTITY_FIELDS = [
145
145
  ] as const;
146
146
 
147
147
  /** The spawn identity fields plus the Agent name, which only an open request carries. */
148
- const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS, "declaredExtensions"] as const;
148
+ const SPAWN_OPEN_FIELDS = [
149
+ "name",
150
+ ...SPAWN_IDENTITY_FIELDS,
151
+ "declaredExtensions",
152
+ "parent",
153
+ ] as const;
149
154
 
150
155
  /** The listed fields whose canonical JSON differs between two identity records. */
151
156
  function changedAmong<Key extends string>(
@@ -216,6 +221,7 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
216
221
  ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
217
222
  ? {}
218
223
  : { declaredExtensions: options.declaredExtensions }),
224
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
219
225
  }),
220
226
  );
221
227
  return hasher.digest("hex");
package/src/events.ts CHANGED
@@ -20,6 +20,12 @@ export type { ModelErrorReason } from "./model/index.ts";
20
20
  */
21
21
  export type RunOutcome = "completed" | "failed" | "stopped" | "paused" | "interrupted";
22
22
 
23
+ /**
24
+ * How an Agent came to be. An absent value on an event means "spawn"; "fork"
25
+ * arrives with the forking spec.
26
+ */
27
+ export type SpawnOrigin = "spawn" | "fork";
28
+
23
29
  /** The current, Ask-scoped observer projection derived from Agent frames. */
24
30
  export type AgentActivity =
25
31
  | { readonly type: "thinking" }
@@ -65,6 +71,10 @@ export type LifecycleEventBody =
65
71
  * Cassette-playback Agents and events from older CLIs.
66
72
  */
67
73
  readonly sessionFile?: string;
74
+ /** Resolved name of the Agent named as this Agent's parent (Parent Link). */
75
+ readonly parent?: string;
76
+ /** How the Agent came to be; absent means "spawn". */
77
+ readonly origin?: SpawnOrigin;
68
78
  }
69
79
  | {
70
80
  readonly type: "ask_start";
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ export type {
47
47
  LifecycleEventBody,
48
48
  NodeState,
49
49
  NodeUsage,
50
+ SpawnOrigin,
50
51
  StampedEventSink,
51
52
  } from "./events.ts";
52
53
  export type {
@@ -20,4 +20,5 @@ export type {
20
20
  IdleAgentInfo,
21
21
  ModelFallbackInfo,
22
22
  NodeInfo,
23
+ SpawnOrigin,
23
24
  } from "./summary-agent.ts";
@@ -1,9 +1,9 @@
1
- import type { AgentActivity } from "../events.ts";
1
+ import type { AgentActivity, SpawnOrigin } from "../events.ts";
2
2
  import type { TokenBreakdown, WorktreeResolution } from "../transport/index.ts";
3
3
  import type { ModelFallbackInfo } from "./summary-fallbacks.ts";
4
4
  import type { NodeInfo } from "./summary-nodes.ts";
5
5
 
6
- export type { AgentActivity } from "../events.ts";
6
+ export type { AgentActivity, SpawnOrigin } from "../events.ts";
7
7
  export type { ModelFallbackInfo } from "./summary-fallbacks.ts";
8
8
  export type { NodeInfo } from "./summary-nodes.ts";
9
9
 
@@ -22,6 +22,10 @@ interface AgentInfoBase {
22
22
  readonly branch: string | null;
23
23
  /** pi's session file for this Agent, when the spawn reported one; a Peek reads it. */
24
24
  readonly sessionFile: string | null;
25
+ /** The Agent named as this one's parent (Parent Link), or null for a root Agent. */
26
+ readonly parent: string | null;
27
+ /** How the Agent came to be; "spawn" until forking ships. */
28
+ readonly origin: SpawnOrigin;
25
29
  readonly activity: AgentActivity | null;
26
30
  readonly tokens: TokenBreakdown | null;
27
31
  readonly cost: number | null;
@@ -97,6 +101,8 @@ export function placeholderAgent(): IdleAgentInfo {
97
101
  cwd: null,
98
102
  branch: null,
99
103
  sessionFile: null,
104
+ parent: null,
105
+ origin: "spawn",
100
106
  state: "idle",
101
107
  askIndex: null,
102
108
  promptGist: null,
@@ -125,6 +131,8 @@ export function spawnAgent(
125
131
  readonly cwd: string;
126
132
  readonly branch?: string;
127
133
  readonly sessionFile?: string;
134
+ readonly parent?: string;
135
+ readonly origin?: SpawnOrigin;
128
136
  },
129
137
  at: number | null,
130
138
  ): AgentRecord {
@@ -135,6 +143,8 @@ export function spawnAgent(
135
143
  cwd: identity.cwd,
136
144
  branch: identity.branch ?? null,
137
145
  sessionFile: identity.sessionFile ?? null,
146
+ parent: identity.parent ?? null,
147
+ origin: identity.origin ?? "spawn",
138
148
  stateChangedAt: at,
139
149
  };
140
150
  }
@@ -144,6 +154,11 @@ export function spawnAgent(
144
154
  cwd: current.cwd ?? identity.cwd,
145
155
  branch: current.branch ?? identity.branch ?? null,
146
156
  sessionFile: current.sessionFile ?? identity.sessionFile ?? null,
157
+ // Lineage follows the rule above it: a fact already folded wins, and a late
158
+ // spawn only fills what is still missing. `origin` has no missing value —
159
+ // it defaults to "spawn" — so the spawn event is its only authority.
160
+ parent: current.parent ?? identity.parent ?? null,
161
+ origin: identity.origin ?? current.origin,
147
162
  };
148
163
  }
149
164
 
@@ -26,6 +26,7 @@ export type {
26
26
  IdleAgentInfo,
27
27
  ModelFallbackInfo,
28
28
  NodeInfo,
29
+ SpawnOrigin,
29
30
  } from "./summary-agent.ts";
30
31
 
31
32
  /** The observer-facing lifecycle state of a Run. */
@@ -191,6 +191,11 @@ export interface OpenOptions {
191
191
  * Spawn identity only: it never becomes argv, and it is absent when empty.
192
192
  */
193
193
  readonly declaredExtensions?: readonly string[];
194
+ /**
195
+ * Resolved name of the Agent named as this Agent's parent (Parent Link).
196
+ * Spawn identity only: it never becomes argv.
197
+ */
198
+ readonly parent?: string;
194
199
  /** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
195
200
  readonly sessionDir?: string;
196
201
  /** Resumes an existing pi session, translated to `--session <path>`. */
package/src/types.ts CHANGED
@@ -66,6 +66,11 @@ export interface SpawnOptions {
66
66
  readonly name?: string;
67
67
  /** Request a fresh Git worktree. The requested cwd remains the base until spawn resolves. */
68
68
  readonly worktree?: boolean;
69
+ /**
70
+ * Names this Agent's parent in the Run tree (Parent Link). Data only: it is
71
+ * no conversation channel and no lifetime rule.
72
+ */
73
+ readonly parent?: Handle;
69
74
  }
70
75
 
71
76
  /**
@@ -73,8 +78,12 @@ export interface SpawnOptions {
73
78
  *
74
79
  * This is the settled shape, and it is what the Cassette identity hashes
75
80
  * (ADR-0039).
81
+ *
82
+ * `parent` is omitted on purpose: a Handle must never reach this shape, because
83
+ * an Ask records these options into the Cassette. Spawn resolves the Parent
84
+ * Link to a name, which travels in `OpenOptions` and stays out of Ask identity.
76
85
  */
77
- export interface ResolvedSpawnOptions extends Omit<SpawnOptions, "model" | "thinking"> {
86
+ export interface ResolvedSpawnOptions extends Omit<SpawnOptions, "model" | "thinking" | "parent"> {
78
87
  readonly model?: string;
79
88
  readonly thinking?: ThinkingLevel;
80
89
  }
@@ -87,6 +96,8 @@ export interface SpawnOverrides {
87
96
  readonly cwd?: string;
88
97
  /** Request a fresh Git worktree. */
89
98
  readonly worktree?: boolean;
99
+ /** Names this Agent's parent in the Run tree (Parent Link). Data only. */
100
+ readonly parent?: Handle;
90
101
  }
91
102
 
92
103
  /**