@yaag/runtime 0.6.2 → 0.8.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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/agent/agent.ts +38 -2
  3. package/src/agent/define-agent.ts +20 -3
  4. package/src/agent/spawn-extensions.ts +97 -0
  5. package/src/agent/spawn-request.ts +70 -0
  6. package/src/agent/spawn.ts +102 -62
  7. package/src/ask/ask-exchange-events.ts +10 -1
  8. package/src/ask/ask-exchange-options.ts +13 -0
  9. package/src/ask/ask-exchange.ts +77 -9
  10. package/src/ask/index.ts +1 -0
  11. package/src/cassette/cassette-publish.ts +5 -1
  12. package/src/cassette/cassette-replay.ts +23 -4
  13. package/src/cassette/cassette-schema.ts +1 -0
  14. package/src/cassette/cassette.ts +8 -0
  15. package/src/cassette/recording-transport.ts +7 -0
  16. package/src/cassette/replay-divergence.ts +86 -20
  17. package/src/cassette/replay-transport.ts +8 -1
  18. package/src/cassette/resume-transport.ts +14 -1
  19. package/src/config/config-file.ts +67 -0
  20. package/src/config/config-issues.ts +31 -0
  21. package/src/config/config-paths.ts +38 -0
  22. package/src/config/config-schema.ts +25 -0
  23. package/src/config/effective-config.ts +116 -0
  24. package/src/config/index.ts +22 -0
  25. package/src/errors.ts +56 -2
  26. package/src/events.ts +19 -0
  27. package/src/extension/extension-paths.ts +15 -6
  28. package/src/index.ts +14 -0
  29. package/src/model/index.ts +18 -0
  30. package/src/model/model-error-history.ts +36 -0
  31. package/src/model/model-failure.ts +78 -0
  32. package/src/model/model-fallback.ts +15 -0
  33. package/src/model/model-match.ts +99 -0
  34. package/src/model/model-resolution.ts +44 -6
  35. package/src/model/model-swap.ts +81 -0
  36. package/src/model/recorded-resolution.ts +61 -0
  37. package/src/model/resolution-loop.ts +115 -0
  38. package/src/run/run.ts +8 -0
  39. package/src/summary/index.ts +1 -0
  40. package/src/summary/summary-agent.ts +9 -1
  41. package/src/summary/summary-fallbacks.ts +50 -0
  42. package/src/summary/summary.ts +12 -0
  43. package/src/transport/fake-transport.ts +57 -1
  44. package/src/transport/index.ts +4 -0
  45. package/src/transport/live-transport.ts +37 -4
  46. package/src/transport/stderr-tail.ts +32 -0
  47. package/src/transport/transport.ts +16 -0
  48. package/src/types.ts +33 -5
  49. package/src/wire-constants.ts +3 -0
@@ -3,6 +3,7 @@ import { FrameQueue } from "./frame-queue.ts";
3
3
  import { decodeFrames } from "./jsonl.ts";
4
4
  import { piCommand, readModel, readSessionFile, readStats } from "./pi-state.ts";
5
5
  import { reap } from "./reap.ts";
6
+ import { appendStderrTail, stderrDiagnostic } from "./stderr-tail.ts";
6
7
  import {
7
8
  createToolProbe,
8
9
  TOOL_PROBE_EXTENSION_PATH,
@@ -25,6 +26,8 @@ import type {
25
26
  const READY_TIMEOUT_MS = 30_000;
26
27
  /** Cost is a local read answering in ~0ms, but the kill must never block on it. */
27
28
  const STATS_TIMEOUT_MS = 2_000;
29
+ /** How long an exit message waits for stderr, which grandchildren may hold open. */
30
+ const STDERR_DRAIN_MS = 100;
28
31
 
29
32
  /** Agents are real `pi --mode rpc` child processes (ADR-0001). */
30
33
  export const liveTransport: TransportFactory = {
@@ -42,7 +45,11 @@ class LiveTransport implements AgentTransport {
42
45
  model = "";
43
46
 
44
47
  readonly #name: string;
45
- readonly #process: Bun.Subprocess<"pipe", "pipe", "ignore">;
48
+ readonly #process: Bun.Subprocess<"pipe", "pipe", "pipe">;
49
+ /** Last bytes pi wrote to stderr; the only classification signal a spawn gets (ADR-0037). */
50
+ #stderrTail = "";
51
+ /** Settles when stderr reaches EOF, so an exit message can wait for the diagnostic. */
52
+ readonly #stderrDone: Promise<void>;
46
53
  readonly #queue = new FrameQueue();
47
54
  readonly #pending = new Map<string, (frame: Frame) => void>();
48
55
  /** Both halves of tool verification, or null when the spawn promised nothing. */
@@ -60,12 +67,29 @@ class LiveTransport implements AgentTransport {
60
67
  cwd: options.cwd,
61
68
  stdin: "pipe",
62
69
  stdout: "pipe",
63
- stderr: "ignore",
70
+ // pi prints model and startup diagnostics to stderr and exits 1 (main() →
71
+ // reportDiagnostics → process.exit(1)), so stderr carries the only reason
72
+ // yaag can classify. The reader below drains it so pi never blocks.
73
+ stderr: "pipe",
64
74
  // Its own process group, so the Agent sees stdin EOF rather than a signal
65
75
  // when the Orchestrator dies — EOF is the path that reaps grandchildren.
66
76
  detached: true,
67
77
  });
68
78
  void this.#read();
79
+ // A stream error must not surface as an unhandled rejection: the tail is a
80
+ // best-effort diagnostic, never a reason to fail an Agent that started.
81
+ this.#stderrDone = this.#readStderr().catch(() => undefined);
82
+ }
83
+
84
+ /** Drains stderr continuously, keeping only a bounded tail. */
85
+ async #readStderr(): Promise<void> {
86
+ const decoder = new TextDecoder();
87
+ for await (const chunk of readChunks(this.#process.stderr)) {
88
+ this.#stderrTail = appendStderrTail(
89
+ this.#stderrTail,
90
+ decoder.decode(chunk, { stream: true }),
91
+ );
92
+ }
69
93
  }
70
94
 
71
95
  /** Confirms the Agent is up and speaking the protocol, and resolves its model. */
@@ -90,6 +114,8 @@ class LiveTransport implements AgentTransport {
90
114
  // The original startup error remains the useful failure.
91
115
  }
92
116
  if (error instanceof YaagError) throw error;
117
+ // Only the exit path folds in stderr: a tool-contract or timeout failure
118
+ // must stay generic, whatever pi warned about earlier (ADR-0037).
93
119
  const message = error instanceof Error ? error.message : "agent startup failed";
94
120
  throw new YaagError("SPAWN_FAILED", message, this.#name);
95
121
  }
@@ -174,8 +200,15 @@ class LiveTransport implements AgentTransport {
174
200
  timeoutMs,
175
201
  );
176
202
  });
177
- const death = this.#process.exited.then((code) => {
178
- throw new YaagError("SPAWN_FAILED", `agent "${this.#name}" exited (${code})`, this.#name);
203
+ const death = this.#process.exited.then(async (code) => {
204
+ // Detached grandchildren can hold the pipe open, so the drain is bounded:
205
+ // an exit must reject fast, with whatever diagnostic already arrived.
206
+ await Promise.race([this.#stderrDone, Bun.sleep(STDERR_DRAIN_MS)]);
207
+ throw new YaagError(
208
+ "SPAWN_FAILED",
209
+ `agent "${this.#name}" exited (${code})${stderrDiagnostic(this.#stderrTail)}`,
210
+ this.#name,
211
+ );
179
212
  });
180
213
  return Promise.race([response, failure, death]).finally(() => clearTimeout(timer));
181
214
  }
@@ -0,0 +1,32 @@
1
+ /** Bound on the kept stderr tail, so a chatty Agent cannot grow the buffer. */
2
+ export const STDERR_TAIL_BYTES = 4_096;
3
+ /** Bound on the diagnostic folded into a failure message, so it stays readable. */
4
+ export const STDERR_MESSAGE_CHARS = 300;
5
+
6
+ /**
7
+ * Lines pi prints that are warnings, not the reason a process died.
8
+ * `No models match pattern "<p>"` is the dangerous one: it classifies as
9
+ * `not_found` (ADR-0037), so an unfiltered tail would turn an unrelated startup
10
+ * failure of the same Agent into a Model Resolution trigger.
11
+ */
12
+ const WARNINGS: readonly RegExp[] = [/no models match pattern/i, /^\s*warn(ing)?\b/i];
13
+
14
+ /** Appends one chunk to a stderr tail, keeping only the last bounded window. */
15
+ export function appendStderrTail(tail: string, chunk: string): string {
16
+ return (tail + chunk).slice(-STDERR_TAIL_BYTES);
17
+ }
18
+
19
+ /**
20
+ * The exit diagnostic of a stderr tail as a message suffix, or "" when pi
21
+ * printed nothing fatal. Warning lines are dropped, the rest is collapsed to one
22
+ * line and capped, so a failure message stays readable.
23
+ */
24
+ export function stderrDiagnostic(tail: string): string {
25
+ const lines = tail
26
+ .split("\n")
27
+ .map((line) => line.trim())
28
+ .filter((line) => line !== "" && !WARNINGS.some((pattern) => pattern.test(line)));
29
+ const text = lines.join(" ").replace(/\s+/g, " ").trim();
30
+ if (text === "") return "";
31
+ return `: ${text.length > STDERR_MESSAGE_CHARS ? text.slice(-STDERR_MESSAGE_CHARS) : text}`;
32
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CanonicalJsonObject, ReportedResult } from "../ask-contract/index.ts";
2
2
  import type { AskLimitOutcome, AskStalledOutcome } from "../errors.ts";
3
+ import type { RecordedSpawnSelection } from "../model/index.ts";
3
4
  import type { AskOptions, ResolvedSpawnOptions, ThinkingLevel } from "../types.ts";
4
5
 
5
6
  /**
@@ -185,6 +186,11 @@ export interface OpenOptions {
185
186
  readonly resolvedSkillPaths?: readonly string[];
186
187
  /** Absolute launch-ready extension paths resolved above the seam, emitted as repeated `-e` flags. */
187
188
  readonly resolvedExtensionPaths?: readonly string[];
189
+ /**
190
+ * Declared extensions in final concatenation order, unresolved (ADR-0040).
191
+ * Spawn identity only: it never becomes argv, and it is absent when empty.
192
+ */
193
+ readonly declaredExtensions?: readonly string[];
188
194
  /** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
189
195
  readonly sessionDir?: string;
190
196
  /** Resumes an existing pi session, translated to `--session <path>`. */
@@ -209,4 +215,14 @@ export type TransportStartupObserver = (startup: TransportStartup) => void;
209
215
  /** How the layer above obtains transports. Swapped wholesale for replay. */
210
216
  export interface TransportFactory {
211
217
  open(options: OpenOptions, observeStartup?: TransportStartupObserver): Promise<AgentTransport>;
218
+
219
+ /**
220
+ * The resolved selection this factory recorded for the Agent it would open
221
+ * next, so a Cassette-backed spawn adopts the recorded outcome instead of
222
+ * re-running Model Resolution (ADR-0037, ADR-0038, ADR-0039).
223
+ *
224
+ * A pure peek: it must not advance the factory's spawn cursor. Live factories
225
+ * record nothing and omit it.
226
+ */
227
+ recordedSpawn?(options: OpenOptions): RecordedSpawnSelection | undefined;
212
228
  }
package/src/types.ts CHANGED
@@ -12,7 +12,10 @@ export interface SpawnOptions {
12
12
  *
13
13
  * An array is an ordered fallback list and a function picks the next candidate
14
14
  * from the failures so far. Any resolved pattern may carry an inline thinking
15
- * suffix (`"opus-5:medium"`), which wins over `thinking`.
15
+ * suffix (`"opus-5:medium"`), which wins over `thinking`. Each attempt settles
16
+ * the model first, then the thinking level for that model. yaag starts a new
17
+ * attempt only when pi reports `not_found`, `auth`, or `rate_limited`. See
18
+ * `ModelSpec` and `ModelResolver` for the termination rules (ADR-0037).
16
19
  */
17
20
  readonly model?: ModelSpec;
18
21
  /** Replaces the default system prompt (`pi --system-prompt`). */
@@ -20,7 +23,8 @@ export interface SpawnOptions {
20
23
  /**
21
24
  * Sets pi's thinking level; omission preserves pi's default. A function picks
22
25
  * the level from the settled model, and is not consulted for a model pattern
23
- * that carries an inline thinking suffix.
26
+ * that carries an inline thinking suffix. The resolver runs again for each
27
+ * attempt, with the model that settled for that attempt (ADR-0037).
24
28
  */
25
29
  readonly thinking?: ThinkingSpec;
26
30
  /** Appends text to pi's system prompt (`pi --append-system-prompt`). */
@@ -34,6 +38,11 @@ export interface SpawnOptions {
34
38
  * resolve from the Orchestration Program file; each becomes `pi -e <path>`.
35
39
  */
36
40
  readonly extensions?: readonly string[];
41
+ /**
42
+ * Load the Effective Config's `agents.extensions` for this Agent. Default
43
+ * true; false spawns with only the extensions this call declares (ADR-0040).
44
+ */
45
+ readonly configExtensions?: boolean;
37
46
  /**
38
47
  * Tool allowlist layered over the ADR-0026 baseline; omission is tool-free when hermetic.
39
48
  * `disallowedTools` applies last. A non-empty surviving allowlist is verified after startup;
@@ -59,7 +68,12 @@ export interface SpawnOptions {
59
68
  readonly worktree?: boolean;
60
69
  }
61
70
 
62
- /** Spawn options after Model Resolution has settled one model and thinking level. */
71
+ /**
72
+ * Spawn options after Model Resolution has settled one model and thinking level.
73
+ *
74
+ * This is the settled shape, and it is what the Cassette identity hashes
75
+ * (ADR-0039).
76
+ */
63
77
  export interface ResolvedSpawnOptions extends Omit<SpawnOptions, "model" | "thinking"> {
64
78
  readonly model?: string;
65
79
  readonly thinking?: ThinkingLevel;
@@ -84,6 +98,11 @@ export interface SpawnOverrides {
84
98
  * Agent, permits one further turn (or a fixed duration grace), then aborts and
85
99
  * rejects with `ASK_LIMIT` if it has not settled. This leaves the Handle alive.
86
100
  * `timeoutMs` is independent: it kills the Agent and rejects with `ASK_TIMEOUT`.
101
+ *
102
+ * A model failure inside an Ask starts Model Fallback and retries the Ask
103
+ * (ADR-0038). Each attempt gets fresh soft limits, a fresh Stall Watchdog and a
104
+ * fresh output collector. `timeoutMs` is the exception: it is the hard ceiling
105
+ * of the whole Ask, and every attempt shares one deadline.
87
106
  */
88
107
  export interface AskOptions {
89
108
  /** Reject and kill the Agent if it has not settled in time. Unset = no bound. */
@@ -106,7 +125,10 @@ export interface AskOptions {
106
125
  * live Ask by default (ADR-0029). On expiry yaag probes the Agent and either
107
126
  * recovers a missed settlement or rejects with `ASK_STALLED`. Omission uses
108
127
  * the 10-minute default. `false`, and any value that is not above zero,
109
- * disable the watchdog for this Ask.
128
+ * disable the watchdog for this Ask. `ASK_STALLED` is a generic failure: it
129
+ * never starts Model Fallback. If the silence budget ends the Ask before pi
130
+ * reports a model failure, the Ask fails `ASK_STALLED` and no swap happens
131
+ * (ADR-0029).
110
132
  */
111
133
  readonly stallMs?: number | false;
112
134
  /** Per-Ask replacement for the runtime's wrap-up steering message. */
@@ -141,7 +163,10 @@ export interface Handle {
141
163
  readonly cwd: string;
142
164
  /** Fresh branch for a worktree Agent; undefined for ordinary Agents. */
143
165
  readonly branch: string | undefined;
144
- /** Model id as reported by the Agent's own `get_state` — not the requested pattern. */
166
+ /**
167
+ * Model id as reported by the Agent's own `get_state` — not the requested
168
+ * pattern. A mid-Ask model swap updates it (ADR-0038).
169
+ */
145
170
  readonly model: string;
146
171
 
147
172
  /**
@@ -155,6 +180,9 @@ export interface Handle {
155
180
  * (default 3) and then rejects recoverably with `ASK_INVALID_OUTPUT` after an
156
181
  * abort settlement; a reported value the schema rejects fails the same way
157
182
  * without a correction. These recoverable outcomes leave the Handle reusable.
183
+ * A retry after a Model Fallback discards the result the failed attempt
184
+ * reported, and arms the collector again, so a stale result cannot settle the
185
+ * retried Ask (ADR-0032, ADR-0038).
158
186
  * A concurrent call rejects with `AGENT_BUSY`.
159
187
  */
160
188
  ask<Schema extends TSchema>(
@@ -20,5 +20,8 @@ export const NODE_GIST_MAX_CHARS = 120;
20
20
  /** Maximum Nested Nodes the Summary keeps per Agent; past it, exited nodes prune oldest-first. */
21
21
  export const AGENT_NODE_TABLE_MAX = 64;
22
22
 
23
+ /** Maximum Model Resolution fallbacks the Summary keeps per Agent; past it, the oldest prune. */
24
+ export const AGENT_MODEL_FALLBACK_MAX = 16;
25
+
23
26
  /** Prefix marking that the oldest pending Ask output was dropped to fit the wire cap. */
24
27
  export const ASK_OUTPUT_TRUNCATION_MARKER = "[…output truncated…]";