@yaag/runtime 0.1.4 → 0.2.1

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/agent.ts +11 -3
  3. package/src/ask-contract-identity.ts +23 -5
  4. package/src/ask-exchange-events.ts +14 -1
  5. package/src/ask-exchange-options.ts +7 -2
  6. package/src/ask-exchange.ts +198 -26
  7. package/src/ask-hash.ts +7 -3
  8. package/src/ask-limit.ts +27 -11
  9. package/src/ask-output-steering.ts +1 -3
  10. package/src/ask-output.ts +20 -4
  11. package/src/ask-turn.ts +11 -0
  12. package/src/cassette-loader.ts +24 -0
  13. package/src/cassette-replay.ts +76 -7
  14. package/src/cassette-schema.ts +17 -2
  15. package/src/cassette.ts +10 -9
  16. package/src/checkpoint-flush.ts +101 -0
  17. package/src/connection.ts +20 -0
  18. package/src/define-agent.ts +11 -5
  19. package/src/errors.ts +11 -2
  20. package/src/events.ts +29 -3
  21. package/src/fake-transport.ts +55 -0
  22. package/src/frame-queue.ts +5 -0
  23. package/src/index.ts +11 -1
  24. package/src/model-resolution.ts +119 -0
  25. package/src/model-suffix.ts +24 -0
  26. package/src/pi-state.ts +63 -8
  27. package/src/recording-transport.ts +8 -8
  28. package/src/replay-divergence.ts +1 -0
  29. package/src/replay-transport.ts +4 -0
  30. package/src/report-result-extension.ts +128 -0
  31. package/src/report-result-output.ts +73 -0
  32. package/src/report-result-steering.ts +83 -0
  33. package/src/report-result.ts +122 -0
  34. package/src/resume-transport.ts +26 -8
  35. package/src/run-checkpoint.ts +93 -41
  36. package/src/run.ts +86 -32
  37. package/src/spawn.ts +29 -4
  38. package/src/stall-watchdog.ts +193 -0
  39. package/src/summary-agent.ts +11 -2
  40. package/src/summary.ts +23 -2
  41. package/src/thinking-level.ts +32 -0
  42. package/src/transport.ts +43 -8
  43. package/src/types.ts +50 -14
  44. package/src/validation-errors.ts +10 -4
package/src/errors.ts CHANGED
@@ -7,12 +7,18 @@ export interface AskLimitOutcome {
7
7
  readonly count: number;
8
8
  }
9
9
 
10
+ import type { AgentProgress } from "./pi-state.ts";
11
+
12
+ export type { AgentProgress } from "./pi-state.ts";
13
+
10
14
  /** The recorded result when yaag rejects an Ask because no frame arrived within `idleMs`. */
11
15
  export interface AskStalledOutcome {
12
16
  /** The configured silence threshold that tripped. */
13
17
  readonly idleMs: number;
14
- /** True when abort failed to settle the Agent and yaag killed the process. */
18
+ /** True when escalation failed to settle the Agent and yaag killed the process. */
15
19
  readonly destructive: boolean;
20
+ /** State the Stall Watchdog probe observed; absent when the probe stayed silent. */
21
+ readonly state?: AgentProgress;
16
22
  }
17
23
 
18
24
  /** Recoverable result when all structured-output correction efforts are exhausted. */
@@ -30,7 +36,7 @@ export type YaagErrorCode =
30
36
  | "AGENT_BUSY" // concurrent ask() on one Handle
31
37
  | "ASK_TIMEOUT" // timeoutMs elapsed
32
38
  | "ASK_LIMIT" // soft Ask budget exceeded after grace
33
- | "ASK_STALLED" // no frame arrived within idleMs; escalated by abort, then kill
39
+ | "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
34
40
  | "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
35
41
  | "ARGS_INVALID" // arguments failed schema validation before the Run started
36
42
  | "OPTIONS_CONFLICT" // incompatible Run options were supplied
@@ -54,6 +60,8 @@ export class YaagError extends Error {
54
60
  readonly idleMs?: number;
55
61
  /** True when an `ASK_STALLED` escalation had to kill the Agent. */
56
62
  readonly destructive?: boolean;
63
+ /** State observed by the Stall Watchdog probe, present only for `ASK_STALLED`. */
64
+ readonly state?: AgentProgress;
57
65
  /** Number of corrective output steers sent, present only for `ASK_INVALID_OUTPUT`. */
58
66
  readonly steeringEfforts?: number;
59
67
  /** Localized extraction or schema errors, present only for `ASK_INVALID_OUTPUT`. */
@@ -76,6 +84,7 @@ export class YaagError extends Error {
76
84
  if (options !== undefined && "idleMs" in options) {
77
85
  this.idleMs = options.idleMs;
78
86
  this.destructive = options.destructive;
87
+ if (options.state !== undefined) this.state = options.state;
79
88
  }
80
89
  if (options !== undefined && "steeringEfforts" in options) {
81
90
  this.steeringEfforts = options.steeringEfforts;
package/src/events.ts CHANGED
@@ -6,10 +6,17 @@
6
6
  * activity, output, and usage observations. v0 renders them as stderr lines; the
7
7
  * dedicated fd arrives with the extension.
8
8
  */
9
+ import type { SettlementCause } from "./stall-watchdog.ts";
9
10
  import type { TokenBreakdown, WorktreeResolution } from "./transport.ts";
10
11
 
11
- /** A settled Run's terminal outcome (ADR-0022). `paused` arrives with the pause slice. */
12
- export type RunOutcome = "completed" | "failed" | "stopped" | "paused";
12
+ export type { SettlementCause } from "./stall-watchdog.ts";
13
+
14
+ /**
15
+ * A Run's outcome (ADR-0022). `paused` arrives with the pause slice.
16
+ * `interrupted` is not terminal: only an in-flight Checkpoint carries it, and a
17
+ * settled Run never writes it (ADR-0031).
18
+ */
19
+ export type RunOutcome = "completed" | "failed" | "stopped" | "paused" | "interrupted";
13
20
 
14
21
  /** The current, Ask-scoped observer projection derived from Agent frames. */
15
22
  export type AgentActivity =
@@ -32,7 +39,16 @@ export interface NodeUsage {
32
39
  }
33
40
 
34
41
  export type LifecycleEventBody =
35
- | { readonly type: "run_start"; readonly program: string }
42
+ | {
43
+ readonly type: "run_start";
44
+ readonly program: string;
45
+ /**
46
+ * Path of the in-progress Checkpoint this Run publishes at each Ask
47
+ * boundary (ADR-0031). A hard death leaves this file loadable with the
48
+ * outcome `interrupted`, so an observer can name the resume source.
49
+ */
50
+ readonly artifact?: string;
51
+ }
36
52
  | {
37
53
  readonly type: "agent_spawn";
38
54
  readonly agent: string;
@@ -96,6 +112,11 @@ export type LifecycleEventBody =
96
112
  readonly ok: boolean;
97
113
  /** Largest inter-frame silence during this Ask; absent during Cassette playback. */
98
114
  readonly maxFrameGapMs?: number;
115
+ /**
116
+ * How the Ask settled. Absent means `normal`. `recovered` means the Stall
117
+ * Watchdog settled it from observed state, so its usage is an undercount.
118
+ */
119
+ readonly cause?: SettlementCause;
99
120
  }
100
121
  | {
101
122
  readonly type: "agent_usage";
@@ -128,6 +149,11 @@ export type LifecycleEventBody =
128
149
  readonly incomplete: boolean;
129
150
  /** Largest inter-frame silence across every Ask; 0 when nothing was measured. */
130
151
  readonly worstFrameGapMs: number;
152
+ /**
153
+ * The Run failed, `--record` asked for a Checkpoint, and publication also
154
+ * failed. Carries the publication error text. Absent on every other path.
155
+ */
156
+ readonly checkpointLost?: string;
131
157
  };
132
158
 
133
159
  /**
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_WRAP_UP_PROMPT } from "./ask-limit.ts";
2
2
  import { FrameQueue } from "./frame-queue.ts";
3
3
  import { parseFrame } from "./jsonl.ts";
4
+ import { isReportResultCommandFrame, REPORT_RESULT_TOOL_NAME } from "./report-result.ts";
4
5
  import type { AgentStats, AgentTransport, AskMarker, Frame } from "./transport.ts";
5
6
 
6
7
  /**
@@ -37,10 +38,16 @@ export interface FakePromptScript {
37
38
  export interface FakeTransportOptions extends FakePromptScript {
38
39
  /** One script per Ask's initial prompt, used in order; preserves the single-script shorthand above. */
39
40
  readonly scripts?: readonly FakePromptScript[];
41
+ /** Payload of a `get_state` probe; omission answers with an idle, non-streaming Agent. */
42
+ readonly state?: Record<string, unknown>;
43
+ /** Makes the fake ignore every `get_state` probe, as a wedged Agent does. */
44
+ readonly stateSilent?: boolean;
40
45
  /** Makes a steer RPC response fail without embedding a limit decision in playback. */
41
46
  readonly steerError?: string;
42
47
  /** Makes an abort RPC response fail without embedding a limit decision in playback. */
43
48
  readonly abortError?: string;
49
+ /** Makes the private report_result schema command fail. */
50
+ readonly schemaCommandError?: string;
44
51
  readonly stats?: AgentStats;
45
52
  /** pi can answer a command after `agent_settled` — it is last among events only. */
46
53
  readonly promptResponse?: "immediate" | "after-settle";
@@ -86,6 +93,7 @@ export class FakeTransport implements AgentTransport {
86
93
  if (frame.type === "steer") void this.#answerSteer(frame);
87
94
  if (frame.type === "abort") void this.#answerAbort(frame);
88
95
  if (frame.type === "get_last_assistant_text") this.#answerLastText(frame);
96
+ if (frame.type === "get_state") this.#answerState(frame);
89
97
  }
90
98
 
91
99
  frames(): AsyncIterable<Frame> {
@@ -127,12 +135,23 @@ export class FakeTransport implements AgentTransport {
127
135
  return this.#closing;
128
136
  }
129
137
 
138
+ /** Pushes one unsolicited frame, as a late or abandoned turn would emit. */
139
+ emit(frame: Frame): void {
140
+ this.#queue.push(frame);
141
+ }
142
+
130
143
  /** Simulates the process dying: the frame stream just ends. */
131
144
  die(): void {
132
145
  this.#queue.end();
133
146
  }
134
147
 
135
148
  async #answerPrompt(frame: Frame): Promise<void> {
149
+ // pi answers an extension command without starting a turn, so the private
150
+ // schema command selects no script and plays no frames (ADR-0032).
151
+ if (isReportResultCommandFrame(frame)) {
152
+ this.#answerControl(frame, this.#options.schemaCommandError);
153
+ return;
154
+ }
136
155
  const script = this.#selectPromptScript();
137
156
  const error = script.promptError ?? this.#options.promptError;
138
157
  const response = {
@@ -214,6 +233,23 @@ export class FakeTransport implements AgentTransport {
214
233
  }
215
234
  }
216
235
 
236
+ #answerState(frame: Frame): void {
237
+ if (this.#options.stateSilent === true) return;
238
+ this.#queue.push({
239
+ type: "response",
240
+ command: "get_state",
241
+ id: frame.id,
242
+ success: true,
243
+ data: this.#options.state ?? {
244
+ model: { provider: "test", id: "model" },
245
+ isStreaming: false,
246
+ isCompacting: false,
247
+ messageCount: 1,
248
+ pendingMessageCount: 0,
249
+ },
250
+ });
251
+ }
252
+
217
253
  #answerLastText(frame: Frame): void {
218
254
  const text = this.#currentScript?.lastText ?? this.#options.lastText;
219
255
  this.#queue.push({
@@ -226,6 +262,25 @@ export class FakeTransport implements AgentTransport {
226
262
  }
227
263
  }
228
264
 
265
+ /** The frames one accepted `report_result` call produces, for scripted Asks. */
266
+ export function reportResultFrames(value: unknown, toolCallId = "call-1"): readonly Frame[] {
267
+ return [
268
+ {
269
+ type: "tool_execution_start",
270
+ toolCallId,
271
+ toolName: REPORT_RESULT_TOOL_NAME,
272
+ args: value,
273
+ },
274
+ {
275
+ type: "tool_execution_end",
276
+ toolCallId,
277
+ toolName: REPORT_RESULT_TOOL_NAME,
278
+ result: { content: [], details: { reportedResult: value }, terminate: true },
279
+ isError: false,
280
+ },
281
+ ];
282
+ }
283
+
229
284
  /**
230
285
  * Loads and parses a named JSONL frame fixture from the runtime fixtures directory.
231
286
  *
@@ -23,6 +23,11 @@ export class FrameQueue {
23
23
  this.#buffered.push(frame);
24
24
  }
25
25
 
26
+ /** Drops every buffered frame the consumer has not taken yet. */
27
+ discardPending(): void {
28
+ this.#buffered = [];
29
+ }
30
+
26
31
  /** Ends the stream; the consumer's iteration finishes once drained. */
27
32
  end(): void {
28
33
  if (this.#ended) return;
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export type {
9
9
  CassetteSpawn,
10
10
  } from "./cassette.ts";
11
11
  export { CASSETTE_VERSION } from "./cassette.ts";
12
- export { loadCassette } from "./cassette-loader.ts";
12
+ export { assertReplayable, loadCassette } from "./cassette-loader.ts";
13
13
  export type { AgentConfig, AgentDefinition } from "./define-agent.ts";
14
14
  export { agentDefinitionConfig, defineAgent, isAgentDefinition } from "./define-agent.ts";
15
15
  export type { OrchestrationProgram, ProgramDefinition } from "./define-run.ts";
@@ -32,6 +32,15 @@ export type {
32
32
  NodeUsage,
33
33
  StampedEventSink,
34
34
  } from "./events.ts";
35
+ export type {
36
+ ModelError,
37
+ ModelErrorReason,
38
+ ModelResolver,
39
+ ModelSelection,
40
+ ModelSpec,
41
+ ThinkingResolver,
42
+ ThinkingSpec,
43
+ } from "./model-resolution.ts";
35
44
  export type { DecodedNode, NodeDecoder } from "./node-decoder.ts";
36
45
  export { DEFAULT_NODE_DECODERS } from "./node-decoders.ts";
37
46
  export type { NodePath } from "./node-path.ts";
@@ -79,6 +88,7 @@ export type {
79
88
  export type {
80
89
  AskOptions,
81
90
  Handle,
91
+ ResolvedSpawnOptions,
82
92
  SpawnOptions,
83
93
  SpawnOverrides,
84
94
  StructuredAskOptions,
@@ -0,0 +1,119 @@
1
+ import { parseModelSuffix } from "./model-suffix.ts";
2
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking-level.ts";
3
+
4
+ /** Why one model candidate was rejected by pi. */
5
+ export type ModelErrorReason = "not_found" | "auth" | "rate_limited";
6
+
7
+ /** One failed Model Resolution attempt, handed back to the caller's resolver. */
8
+ export interface ModelError {
9
+ readonly reason: ModelErrorReason;
10
+ /** The model pattern that failed, after inline-suffix stripping. */
11
+ readonly failedModel: string;
12
+ /** 0-based index of the attempt that produced this error. */
13
+ readonly attempt: number;
14
+ }
15
+
16
+ /** Picks the next model candidate, or `undefined` to give up. */
17
+ export type ModelResolver = (errors: readonly ModelError[]) => string | undefined;
18
+
19
+ /** Every accepted `model` form: one pattern, an ordered list, or a resolver. */
20
+ export type ModelSpec = string | readonly string[] | ModelResolver;
21
+
22
+ /** Picks the thinking level for a settled model, or `undefined` for pi's default. */
23
+ export type ThinkingResolver = (
24
+ selectedModel: string,
25
+ errors: readonly ModelError[],
26
+ ) => ThinkingLevel | undefined;
27
+
28
+ /** Every accepted `thinking` form: one level or a resolver. */
29
+ export type ThinkingSpec = ThinkingLevel | ThinkingResolver;
30
+
31
+ /** One attempt's settled selection; `model: undefined` inherits pi's default model. */
32
+ export interface ModelSelection {
33
+ readonly model: string | undefined;
34
+ readonly thinking: ThinkingLevel | undefined;
35
+ }
36
+
37
+ /** The canonical shape the spawn loop consumes, whatever form the caller wrote. */
38
+ export interface ModelResolution {
39
+ /** The next selection given the errors so far, or `undefined` when candidates run out. */
40
+ resolve(errors: readonly ModelError[]): ModelSelection | undefined;
41
+ }
42
+
43
+ /**
44
+ * Turns any accepted `model`/`thinking` form into one canonical resolver pair.
45
+ *
46
+ * Within one attempt the order is: model resolver → inline-suffix parse →
47
+ * thinking resolver, and an inline suffix wins over the `thinking` spec, which
48
+ * is then not consulted at all. An absent `model` inherits pi's default for
49
+ * attempt 0 only; a thinking *resolver* is then not called, because no model has
50
+ * settled, while a thinking level still applies.
51
+ * Array specs are copied, so later caller mutation cannot change resolution.
52
+ * Throws a `TypeError` when a caller resolver returns something other than a
53
+ * non-empty model string or a valid thinking level.
54
+ */
55
+ export function normalizeModelResolution(spec: {
56
+ readonly model?: ModelSpec;
57
+ readonly thinking?: ThinkingSpec;
58
+ }): ModelResolution {
59
+ const model = toModelResolver(spec.model);
60
+ const thinking = toThinkingResolver(spec.thinking);
61
+ const literalThinking = typeof spec.thinking === "function" ? undefined : spec.thinking;
62
+ return {
63
+ resolve(errors: readonly ModelError[]): ModelSelection | undefined {
64
+ if (model === undefined) {
65
+ if (errors.length > 0) return undefined;
66
+ return { model: undefined, thinking: checkedThinking(literalThinking) };
67
+ }
68
+ const pattern = checkedModel(model(errors));
69
+ if (pattern === undefined) return undefined;
70
+ const parsed = parseModelSuffix(pattern);
71
+ if (parsed.thinking !== undefined) return parsed;
72
+ return { model: parsed.model, thinking: checkedThinking(thinking(parsed.model, errors)) };
73
+ },
74
+ };
75
+ }
76
+
77
+ function toModelResolver(spec: ModelSpec | undefined): ModelResolver | undefined {
78
+ if (spec === undefined) return undefined;
79
+ if (typeof spec === "string") {
80
+ return (errors) => (errors.length === 0 ? spec : undefined);
81
+ }
82
+ if (Array.isArray(spec)) {
83
+ const candidates: readonly string[] = [...spec];
84
+ return (errors) => candidates[errors.length];
85
+ }
86
+ if (typeof spec === "function") return spec;
87
+ throw new TypeError(`model must be a string, an array of strings, or a function: ${show(spec)}`);
88
+ }
89
+
90
+ function toThinkingResolver(spec: ThinkingSpec | undefined): ThinkingResolver {
91
+ if (spec === undefined) return () => undefined;
92
+ if (typeof spec === "function") return spec;
93
+ if (isThinkingLevel(spec)) return () => spec;
94
+ throw new TypeError(`thinking must be a pi thinking level or a function: ${show(spec)}`);
95
+ }
96
+
97
+ function checkedModel(value: unknown): string | undefined {
98
+ if (value === undefined) return undefined;
99
+ if (typeof value !== "string" || value === "") {
100
+ throw new TypeError(
101
+ `model resolver must return a non-empty string or undefined: ${show(value)}`,
102
+ );
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function checkedThinking(value: unknown): ThinkingLevel | undefined {
108
+ if (value === undefined) return undefined;
109
+ if (!isThinkingLevel(value)) {
110
+ throw new TypeError(
111
+ `thinking resolver must return a pi thinking level or undefined: ${show(value)}`,
112
+ );
113
+ }
114
+ return value;
115
+ }
116
+
117
+ function show(value: unknown): string {
118
+ return typeof value === "string" ? JSON.stringify(value) : String(value);
119
+ }
@@ -0,0 +1,24 @@
1
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking-level.ts";
2
+
3
+ /** A model pattern split into its model part and its optional inline thinking suffix. */
4
+ export interface ModelPattern {
5
+ readonly model: string;
6
+ readonly thinking: ThinkingLevel | undefined;
7
+ }
8
+
9
+ /**
10
+ * Splits a resolved model pattern on its inline thinking suffix.
11
+ *
12
+ * The split happens on the *last* `:` only when the tail is exactly one of pi's
13
+ * thinking levels and the head is non-empty, so colon-bearing model ids such as
14
+ * `llama3:8b` and suffix-only strings such as `:medium` survive untouched. The
15
+ * parser is total: it never throws, and an unusable pattern simply fails later
16
+ * as an ordinary model lookup failure.
17
+ */
18
+ export function parseModelSuffix(pattern: string): ModelPattern {
19
+ const separator = pattern.lastIndexOf(":");
20
+ if (separator <= 0) return { model: pattern, thinking: undefined };
21
+ const tail = pattern.slice(separator + 1);
22
+ if (!isThinkingLevel(tail)) return { model: pattern, thinking: undefined };
23
+ return { model: pattern.slice(0, separator), thinking: tail };
24
+ }
package/src/pi-state.ts CHANGED
@@ -1,5 +1,16 @@
1
+ import { REPORT_RESULT_EXTENSION_PATH, REPORT_RESULT_TOOL_NAME } from "./report-result.ts";
1
2
  import type { AgentStats, Frame, OpenOptions, TokenBreakdown } from "./transport.ts";
2
3
 
4
+ /** What the Agent reported about its own work, read from a `get_state` probe. */
5
+ export interface AgentProgress {
6
+ /** True while pi streams a completion, compacts its context, or holds a queued prompt. */
7
+ readonly working: boolean;
8
+ readonly streaming: boolean;
9
+ readonly compacting: boolean;
10
+ /** Prompts pi has queued but not started. */
11
+ readonly pendingMessages: number;
12
+ }
13
+
3
14
  /** The command line for one Agent. */
4
15
  export function piCommand(options: OpenOptions, toolProbeExtensionPath?: string): string[] {
5
16
  const cmd = ["pi", "--mode", "rpc"];
@@ -22,13 +33,7 @@ function appendCapabilities(
22
33
  toolProbeExtensionPath: string | undefined,
23
34
  ): void {
24
35
  const hermetic = options.inherit !== true;
25
- if (options.tools === undefined) {
26
- if (hermetic) cmd.push("--no-tools");
27
- } else if (options.tools.length === 0) {
28
- cmd.push("--no-tools");
29
- } else {
30
- cmd.push("--tools", options.tools.join(","));
31
- }
36
+ appendTools(cmd, options, hermetic);
32
37
  if (options.resolvedSkillPaths === undefined) {
33
38
  if (hermetic) cmd.push("--no-skills");
34
39
  } else {
@@ -39,6 +44,10 @@ function appendCapabilities(
39
44
  cmd.push("--no-extensions", "--no-context-files", "--no-prompt-templates");
40
45
  }
41
46
  for (const path of options.resolvedExtensionPaths ?? []) cmd.push("-e", path);
47
+ // Every Agent carries the report-result tool, because an Ask declares its
48
+ // output schema long after the process started and pi loads extensions only
49
+ // at startup (ADR-0032).
50
+ cmd.push("-e", REPORT_RESULT_EXTENSION_PATH);
42
51
  if (
43
52
  options.tools !== undefined &&
44
53
  options.tools.length > 0 &&
@@ -47,10 +56,34 @@ function appendCapabilities(
47
56
  cmd.push("-e", toolProbeExtensionPath);
48
57
  }
49
58
  if (options.disallowedTools !== undefined) {
50
- cmd.push("--exclude-tools", options.disallowedTools.join(","));
59
+ // The private tool is never a program's to deny: excluding it would fail
60
+ // every schema-bearing Ask with "agent did not call report_result". A
61
+ // denylist of only that name leaves nothing to exclude.
62
+ const denied = options.disallowedTools.filter((name) => name !== REPORT_RESULT_TOOL_NAME);
63
+ // An explicit empty denylist still emits its empty flag, as it always has.
64
+ const emitsExcludeTools = denied.length > 0 || options.disallowedTools.length === 0;
65
+ if (emitsExcludeTools) cmd.push("--exclude-tools", denied.join(","));
51
66
  }
52
67
  }
53
68
 
69
+ /**
70
+ * Expresses the tool baseline as an allowlist that keeps `report_result`.
71
+ *
72
+ * `--no-tools` strips extension tools as well, and refuses a tool registered
73
+ * afterwards, so "no tools" becomes an allowlist of exactly yaag's own tool
74
+ * (e2e/report-result-spike.test.ts). The tool stays inactive until a
75
+ * schema-bearing Ask activates it, so the Agent's effective capability is
76
+ * unchanged.
77
+ */
78
+ function appendTools(cmd: string[], options: OpenOptions, hermetic: boolean): void {
79
+ if (options.tools === undefined) {
80
+ if (hermetic) cmd.push("--tools", REPORT_RESULT_TOOL_NAME);
81
+ return;
82
+ }
83
+ const allowed = [...new Set([...options.tools, REPORT_RESULT_TOOL_NAME])];
84
+ cmd.push("--tools", allowed.join(","));
85
+ }
86
+
54
87
  /**
55
88
  * `provider/id` from a `get_state` response — what the Agent actually resolved
56
89
  * to, not the pattern that was requested. Null when the payload has no model.
@@ -66,6 +99,28 @@ export function readModel(response: Frame): string | null {
66
99
  return provider ? `${provider}/${model.id}` : model.id;
67
100
  }
68
101
 
102
+ /**
103
+ * Reads what a `get_state` payload says about the Agent's own work.
104
+ *
105
+ * Null when the payload does not carry pi's streaming flags, which the Stall
106
+ * Watchdog treats as work in progress rather than as a settlement.
107
+ */
108
+ export function readAgentProgress(data: unknown): AgentProgress | null {
109
+ if (typeof data !== "object" || data === null) return null;
110
+ const record: Record<string, unknown> = { ...data };
111
+ if (typeof record.isStreaming !== "boolean") return null;
112
+ const streaming = record.isStreaming;
113
+ const compacting = record.isCompacting === true;
114
+ const pendingMessages =
115
+ typeof record.pendingMessageCount === "number" ? record.pendingMessageCount : 0;
116
+ return {
117
+ working: streaming || compacting || pendingMessages > 0,
118
+ streaming,
119
+ compacting,
120
+ pendingMessages,
121
+ };
122
+ }
123
+
69
124
  /** Returns pi's persisted session path from a startup `get_state` response, when valid. */
70
125
  export function readSessionFile(response: Frame): string | null {
71
126
  const data: unknown = response.data;
@@ -1,9 +1,9 @@
1
1
  import type { CassetteSink } from "./cassette.ts";
2
- import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
3
2
  import { readGitFacts } from "./git-facts.ts";
4
3
  import type {
5
4
  AgentStats,
6
5
  AgentTransport,
6
+ AskCompletion,
7
7
  AskMarker,
8
8
  AskPlayback,
9
9
  Frame,
@@ -75,13 +75,13 @@ class RecordingTransport implements AgentTransport {
75
75
  return this.#inner.beginAsk(marker);
76
76
  }
77
77
 
78
- finishAsk(
79
- outcome?: AskLimitOutcome,
80
- stalled?: AskStalledOutcome,
81
- invalidOutput?: import("./transport.ts").AskInvalidOutputPlayback,
82
- ): void {
83
- this.#recorder.finishAsk(outcome, stalled, invalidOutput);
84
- this.#inner.finishAsk(outcome, stalled, invalidOutput);
78
+ finishAsk(completion: AskCompletion): void {
79
+ this.#recorder.finishAsk(completion);
80
+ this.#inner.finishAsk(completion);
81
+ }
82
+
83
+ recordedExtractionPolicy(index: number): string | undefined {
84
+ return this.#inner.recordedExtractionPolicy?.(index);
85
85
  }
86
86
 
87
87
  close(): Promise<AgentStats> {
@@ -114,6 +114,7 @@ function changedFields(expected: AskMarkerContext, actual: AskMarkerContext): re
114
114
  "maxToolCalls",
115
115
  "maxDurationMs",
116
116
  "idleMs",
117
+ "stallMs",
117
118
  "wrapUpPrompt",
118
119
  "outputSchema",
119
120
  "maxSteers",
@@ -65,6 +65,10 @@ class ReplayTransport implements AgentTransport {
65
65
  // A completed replay does not alter the recorded Cassette.
66
66
  }
67
67
 
68
+ recordedExtractionPolicy(index: number): string | undefined {
69
+ return this.#replay.agent.asks[index]?.extractionPolicy;
70
+ }
71
+
68
72
  close(): Promise<AgentStats> {
69
73
  this.#closed ??= Promise.resolve(this.#replay.stats).finally(() => this.#replay.finish());
70
74
  return this.#closed;