@yaag/runtime 0.1.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 (71) hide show
  1. package/package.json +25 -0
  2. package/src/agent-names.ts +20 -0
  3. package/src/agent-usage.ts +72 -0
  4. package/src/agent.ts +130 -0
  5. package/src/args-validation.ts +11 -0
  6. package/src/ask-activity.ts +84 -0
  7. package/src/ask-contract-identity.ts +96 -0
  8. package/src/ask-exchange-events.ts +60 -0
  9. package/src/ask-exchange-options.ts +32 -0
  10. package/src/ask-exchange.ts +291 -0
  11. package/src/ask-hash.ts +86 -0
  12. package/src/ask-limit.ts +189 -0
  13. package/src/ask-output-steering.ts +69 -0
  14. package/src/ask-output-tail.ts +166 -0
  15. package/src/ask-output.ts +109 -0
  16. package/src/ask-settlement.ts +37 -0
  17. package/src/ask-turn.ts +70 -0
  18. package/src/cassette-loader.ts +131 -0
  19. package/src/cassette-publish.ts +55 -0
  20. package/src/cassette-replay.ts +178 -0
  21. package/src/cassette-schema.ts +152 -0
  22. package/src/cassette.ts +275 -0
  23. package/src/checkpoint-dir.ts +89 -0
  24. package/src/connection.ts +123 -0
  25. package/src/define-agent.ts +83 -0
  26. package/src/define-run.ts +69 -0
  27. package/src/errors.ts +115 -0
  28. package/src/events.ts +143 -0
  29. package/src/extension-package.ts +88 -0
  30. package/src/extension-paths.ts +66 -0
  31. package/src/extension-source.ts +60 -0
  32. package/src/fake-transport.ts +240 -0
  33. package/src/frame-gap.ts +41 -0
  34. package/src/frame-queue.ts +52 -0
  35. package/src/git-facts.ts +32 -0
  36. package/src/idle-watch.ts +154 -0
  37. package/src/index.ts +96 -0
  38. package/src/jsonl.ts +42 -0
  39. package/src/live-transport.ts +210 -0
  40. package/src/node-decoder-subagent.ts +67 -0
  41. package/src/node-decoder-workflow.ts +74 -0
  42. package/src/node-decoder.ts +23 -0
  43. package/src/node-decoders.ts +9 -0
  44. package/src/node-details.ts +70 -0
  45. package/src/node-path.ts +36 -0
  46. package/src/node-tracker.ts +143 -0
  47. package/src/pi-state.ts +108 -0
  48. package/src/prompt-gist.ts +13 -0
  49. package/src/prompt.ts +80 -0
  50. package/src/reap.ts +59 -0
  51. package/src/recording-transport.ts +97 -0
  52. package/src/replay-divergence.ts +155 -0
  53. package/src/replay-transport.ts +72 -0
  54. package/src/resume-preconditions.ts +59 -0
  55. package/src/resume-transport.ts +165 -0
  56. package/src/run-checkpoint.ts +93 -0
  57. package/src/run-context.ts +19 -0
  58. package/src/run.ts +274 -0
  59. package/src/skill-probe.ts +247 -0
  60. package/src/skill-restriction-transport.ts +76 -0
  61. package/src/spawn.ts +241 -0
  62. package/src/summary-agent.ts +310 -0
  63. package/src/summary-nodes.ts +77 -0
  64. package/src/summary.ts +213 -0
  65. package/src/tool-probe-extension.ts +17 -0
  66. package/src/tool-probe.ts +141 -0
  67. package/src/transport.ts +178 -0
  68. package/src/types.ts +130 -0
  69. package/src/validation-errors.ts +70 -0
  70. package/src/wire-constants.ts +24 -0
  71. package/src/worktree-transport.ts +125 -0
@@ -0,0 +1,291 @@
1
+ import { AskActivityTracker } from "./ask-activity.ts";
2
+ import { AskEvents } from "./ask-exchange-events.ts";
3
+ import type { AskExchangeOptions } from "./ask-exchange-options.ts";
4
+ import { askHash, askMarkerContext, structuredOutputContract } from "./ask-hash.ts";
5
+ import { AskLimit } from "./ask-limit.ts";
6
+ import { resolvePlaybackAskOutput } from "./ask-output.ts";
7
+ import { AskOutputSteering } from "./ask-output-steering.ts";
8
+ import { AskOutputTail } from "./ask-output-tail.ts";
9
+ import { awaitAskSettlement } from "./ask-settlement.ts";
10
+ import { AskTurn } from "./ask-turn.ts";
11
+ import {
12
+ type AskLimitOutcome,
13
+ type AskStalledOutcome,
14
+ agentError,
15
+ askLimitError,
16
+ askStalledError,
17
+ isYaagError,
18
+ } from "./errors.ts";
19
+ import { FrameGapTracker } from "./frame-gap.ts";
20
+ import { IdleKillSignal, IdleWatch } from "./idle-watch.ts";
21
+ import { NodeTracker } from "./node-tracker.ts";
22
+ import type { AskPlayback } from "./transport.ts";
23
+
24
+ export type { AskExchangeOptions } from "./ask-exchange-options.ts";
25
+ /** Runs one Ask's transport exchange, including controls and structured-output correction. */
26
+ export async function exchangeAsk(options: AskExchangeOptions): Promise<unknown> {
27
+ return new AskExchange(options).run();
28
+ }
29
+ class AskExchange {
30
+ readonly #options: AskExchangeOptions;
31
+ readonly #turn = new AskTurn();
32
+ readonly #events: AskEvents;
33
+ #limit: AskLimit | null = null;
34
+ #idle: IdleWatch | null = null;
35
+ #gap: FrameGapTracker | null = null;
36
+ #activity: AskActivityTracker | null = null;
37
+ #output: AskOutputTail | null = null;
38
+ #nodes: NodeTracker | null = null;
39
+ #playback: AskPlayback | undefined;
40
+ #began = false;
41
+ #outcome: AskLimitOutcome | undefined;
42
+ #stalled: AskStalledOutcome | undefined;
43
+ #invalidOutput: { readonly kind: "invalid_output"; readonly steeringEfforts: number } | undefined;
44
+ #onSettled: () => void = () => {};
45
+ #controlFailure: Promise<never> = new Promise<never>(() => {});
46
+ #timeoutDeadline: number | undefined;
47
+ #maxFrameGapMs: number | undefined;
48
+ constructor(options: AskExchangeOptions) {
49
+ this.#options = options;
50
+ this.#events = new AskEvents(options.emit, options.agent, options.index);
51
+ }
52
+
53
+ async run(): Promise<unknown> {
54
+ const startedAt = Date.now();
55
+ try {
56
+ const result = await this.#exchange();
57
+ this.#events.end(Date.now() - startedAt, true, this.#maxFrameGapMs);
58
+ return result;
59
+ } catch (error) {
60
+ if (this.#began) this.#events.end(Date.now() - startedAt, false, this.#maxFrameGapMs);
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ async #exchange(): Promise<unknown> {
66
+ try {
67
+ this.#begin();
68
+ const settled = this.#armSettlement();
69
+ this.#timeoutDeadline = this.#deadline();
70
+ await this.#sendPrompt();
71
+ await this.#verify(settled);
72
+ const abortSettled =
73
+ this.#playback?.awaitsAbortSettlement === true ? this.#armSettlement() : undefined;
74
+ let result: unknown;
75
+ let outputError: unknown;
76
+ try {
77
+ result = await this.#resolveOutput(await this.#lastAssistantText());
78
+ } catch (error) {
79
+ if (abortSettled === undefined) throw error;
80
+ outputError = error;
81
+ }
82
+ if (abortSettled !== undefined) await this.#verify(abortSettled, true);
83
+ if (outputError !== undefined) throw outputError;
84
+ return result;
85
+ } catch (error) {
86
+ this.#recordInvalidOutput(error);
87
+ throw error;
88
+ } finally {
89
+ this.#cleanup();
90
+ }
91
+ }
92
+
93
+ #begin(): void {
94
+ const outputContract = structuredOutputContract(this.#options.ask);
95
+ const identity = {
96
+ spawnOptions: this.#options.spawnOptions,
97
+ index: this.#options.index,
98
+ prompt: this.#options.prompt,
99
+ ask: this.#options.ask,
100
+ ...(outputContract === undefined ? {} : { outputContract }),
101
+ };
102
+ const playback = this.#options.transport.beginAsk({
103
+ index: this.#options.index,
104
+ hash: askHash(identity),
105
+ ...(outputContract === undefined ? {} : outputContract),
106
+ ...(this.#options.definitionName === undefined
107
+ ? {}
108
+ : { definitionName: this.#options.definitionName, context: askMarkerContext(identity) }),
109
+ });
110
+ this.#began = true;
111
+ this.#playback = playback;
112
+ this.#events.start(this.#options.prompt, playback !== undefined);
113
+ this.#gap = playback === undefined ? new FrameGapTracker() : null;
114
+ this.#activity = playback === undefined ? new AskActivityTracker(this.#events.activity) : null;
115
+ this.#output =
116
+ playback === undefined ? new AskOutputTail({ report: this.#events.output }) : null;
117
+ // Deliberately not gated on live execution, unlike the trackers above: a
118
+ // Nested Node is decoded from recorded Agent frames only, so playback must
119
+ // re-derive the identical `node_update` stream (ADR-0013, spec §3).
120
+ this.#nodes = new NodeTracker({ report: this.#events.node });
121
+ this.#limit = playback === undefined ? this.#createLimit() : null;
122
+ this.#idle = playback === undefined ? this.#createIdleWatch() : null;
123
+ this.#observe();
124
+ this.#limit?.start();
125
+ this.#idle?.start();
126
+ this.#gap?.start();
127
+ this.#controlFailure = this.#createControlFailure();
128
+ }
129
+
130
+ #createLimit(): AskLimit {
131
+ return new AskLimit({
132
+ ask: this.#options.ask,
133
+ durationGraceMs: this.#options.askLimitGraceMs,
134
+ command: async (frame) => (await this.#options.connection.command(frame)).success,
135
+ });
136
+ }
137
+
138
+ #createIdleWatch(): IdleWatch | null {
139
+ if (this.#options.ask.idleMs === undefined) return null;
140
+ return new IdleWatch({
141
+ idleMs: this.#options.ask.idleMs,
142
+ ...(this.#options.idleAbortSettleMs === undefined
143
+ ? {}
144
+ : { abortSettleMs: this.#options.idleAbortSettleMs }),
145
+ command: async (frame) => (await this.#options.connection.command(frame)).success,
146
+ });
147
+ }
148
+
149
+ #observe(): void {
150
+ this.#options.connection.observe((frame) => {
151
+ this.#turn.observe(frame);
152
+ this.#limit?.observe(frame);
153
+ if (frame.type !== "agent_settled" || this.#idle?.tripped) this.#idle?.observe(frame);
154
+ this.#gap?.observe();
155
+ this.#activity?.observe(frame);
156
+ this.#output?.observe(frame);
157
+ this.#nodes?.observe(frame);
158
+ if (this.#playback === undefined) this.#options.usage.observe(frame);
159
+ if (this.#turn.settled) this.#onSettled();
160
+ });
161
+ }
162
+
163
+ #createControlFailure(): Promise<never> {
164
+ const controls = [this.#limit?.failed, this.#idle?.failed, this.#idle?.killed].filter(
165
+ (promise): promise is Promise<never> => promise !== undefined,
166
+ );
167
+ return Promise.race(controls.length === 0 ? [new Promise<never>(() => {})] : controls).catch(
168
+ (error: unknown) => {
169
+ if (error instanceof IdleKillSignal)
170
+ throw askStalledError(this.#options.agent, error.outcome);
171
+ throw this.#failed(error instanceof Error ? error.message : "limit control command failed");
172
+ },
173
+ );
174
+ }
175
+
176
+ #armSettlement(): Promise<void> {
177
+ this.#turn.rearm();
178
+ return new Promise<void>((resolve) => {
179
+ this.#onSettled = resolve;
180
+ });
181
+ }
182
+
183
+ #deadline(): number | undefined {
184
+ return this.#options.ask.timeoutMs === undefined
185
+ ? undefined
186
+ : Date.now() + this.#options.ask.timeoutMs;
187
+ }
188
+
189
+ async #sendPrompt(): Promise<void> {
190
+ const response = await this.#options.connection.command({
191
+ type: "prompt",
192
+ message: this.#options.prompt,
193
+ });
194
+ if (!response.success) throw this.#failed(response.error ?? "prompt was rejected");
195
+ }
196
+
197
+ #failed(message: string): Error {
198
+ return agentError(this.#options.agent, "AGENT_FAILED", message);
199
+ }
200
+
201
+ async #verify(settled: Promise<void>, ignoreAbortFailure = false): Promise<void> {
202
+ await awaitAskSettlement({
203
+ settled,
204
+ closed: this.#options.connection.closed,
205
+ timeoutMs:
206
+ this.#timeoutDeadline === undefined
207
+ ? undefined
208
+ : Math.max(0, this.#timeoutDeadline - Date.now()),
209
+ controlFailure: this.#controlFailure,
210
+ error: (code, message) => agentError(this.#options.agent, code, message),
211
+ close: this.#options.close,
212
+ });
213
+ this.#outcome = this.#playback?.limit ?? this.#limit?.abortOutcome ?? undefined;
214
+ if (this.#outcome !== undefined) throw askLimitError(this.#options.agent, this.#outcome);
215
+ this.#stalled = this.#playback?.stalled ?? this.#idle?.result ?? undefined;
216
+ if (this.#stalled !== undefined) throw askStalledError(this.#options.agent, this.#stalled);
217
+ const failure = this.#turn.failure();
218
+ if (failure !== null && !ignoreAbortFailure) throw this.#failed(failure);
219
+ }
220
+
221
+ async #lastAssistantText(): Promise<string> {
222
+ const response = await this.#options.connection.command({ type: "get_last_assistant_text" });
223
+ const text: unknown = response.data?.text;
224
+ if (typeof text !== "string" || text.trim() === "") {
225
+ throw this.#failed("agent settled without producing any text");
226
+ }
227
+ return text;
228
+ }
229
+
230
+ async #resolveOutput(text: string): Promise<unknown> {
231
+ if (!("outputSchema" in this.#options.ask)) return text;
232
+ if (this.#playback !== undefined) {
233
+ return resolvePlaybackAskOutput(
234
+ this.#options.agent,
235
+ this.#options.ask.outputSchema,
236
+ text,
237
+ this.#playback.outcome,
238
+ );
239
+ }
240
+ return new AskOutputSteering({
241
+ agent: this.#options.agent,
242
+ schema: this.#options.ask.outputSchema,
243
+ maxSteers: this.#options.ask.maxSteers,
244
+ beginCorrection: () => this.#limit?.enterOutputCorrection() ?? true,
245
+ wrappingUp: () => this.#limit?.tripped ?? false,
246
+ correct: async (message) => this.#correct(message),
247
+ abort: async () => this.#abort(),
248
+ }).resolve(text);
249
+ }
250
+
251
+ // A follow-up prompt within the same logical Ask: real pi parks a post-settlement
252
+ // steer behind queue_update and never starts a correction turn (ADR-0027 amendment).
253
+ async #correct(message: string): Promise<string> {
254
+ try {
255
+ const settled = this.#armSettlement();
256
+ const response = await this.#options.connection.command({ type: "prompt", message });
257
+ if (!response.success) throw this.#failed(response.error ?? "correction prompt was rejected");
258
+ await this.#verify(settled);
259
+ return await this.#lastAssistantText();
260
+ } finally {
261
+ this.#limit?.leaveOutputCorrection();
262
+ }
263
+ }
264
+
265
+ async #abort(): Promise<void> {
266
+ const settled = this.#armSettlement();
267
+ const response = await this.#options.connection.command({ type: "abort" });
268
+ if (!response.success) throw this.#failed(response.error ?? "abort was rejected");
269
+ await this.#verify(settled, true);
270
+ }
271
+
272
+ #recordInvalidOutput(error: unknown): void {
273
+ if (this.#playback === undefined && isYaagError(error) && error.code === "ASK_INVALID_OUTPUT")
274
+ this.#invalidOutput = { kind: "invalid_output", steeringEfforts: error.steeringEfforts ?? 0 };
275
+ }
276
+
277
+ #cleanup(): void {
278
+ this.#maxFrameGapMs = this.#gap?.maxGapMs;
279
+ this.#stalled ??= this.#idle?.result ?? undefined;
280
+ this.#limit?.cleanup();
281
+ this.#idle?.cleanup();
282
+ this.#output?.close();
283
+ this.#options.connection.observe(null);
284
+ if (!this.#began) return;
285
+ try {
286
+ this.#options.transport.finishAsk(this.#outcome, this.#stalled, this.#invalidOutput);
287
+ } catch {
288
+ // Recording must not hide the Ask outcome it was observing.
289
+ }
290
+ }
291
+ }
@@ -0,0 +1,86 @@
1
+ import type { TSchema } from "typebox";
2
+ import { type AskOutputContract, createAskOutputContract } from "./ask-contract-identity.ts";
3
+ import type { AskMarkerContext } from "./transport.ts";
4
+ import type { AskOptions, SpawnOptions, StructuredAskOptions } from "./types.ts";
5
+
6
+ /** The combined option shape used internally after Definition defaults merge. */
7
+ export type EffectiveAskOptions = AskOptions | StructuredAskOptions<TSchema>;
8
+
9
+ /** Inputs that identify an Ask in a Cassette. */
10
+ export interface AskHashOptions {
11
+ readonly spawnOptions: SpawnOptions;
12
+ readonly index: number;
13
+ readonly prompt: string;
14
+ /** Soft-limit behavior and structured output contract, unlike timeoutMs, identify replay. */
15
+ readonly ask?: EffectiveAskOptions;
16
+ /** Precomputed once so marker metadata and hashing cannot drift. */
17
+ readonly outputContract?: AskOutputContract;
18
+ }
19
+
20
+ /**
21
+ * Identity of one Ask: sha256 over the prompt and behavioral Agent/Ask options.
22
+ *
23
+ * The optional canonical output schema, explicit steer bound, and extraction
24
+ * policy identify schema-bearing Asks. `timeoutMs` remains excluded.
25
+ */
26
+ export function askHash(options: AskHashOptions): string {
27
+ const { spawnOptions, index, prompt, ask } = options;
28
+ const outputContract = options.outputContract ?? structuredOutputContract(ask);
29
+ const hasher = new Bun.CryptoHasher("sha256");
30
+ hasher.update(
31
+ JSON.stringify({
32
+ cwd: spawnOptions.cwd ?? null,
33
+ model: spawnOptions.model ?? null,
34
+ systemPrompt: spawnOptions.systemPrompt ?? null,
35
+ ...(spawnOptions.thinking === undefined ? {} : { thinking: spawnOptions.thinking }),
36
+ ...(spawnOptions.appendSystemPrompt === undefined
37
+ ? {}
38
+ : { appendSystemPrompt: spawnOptions.appendSystemPrompt }),
39
+ ...(spawnOptions.worktree === true ? { worktree: true } : {}),
40
+ index,
41
+ prompt,
42
+ ...(ask?.maxTurns === undefined ? {} : { maxTurns: ask.maxTurns }),
43
+ ...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
44
+ ...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
45
+ ...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
46
+ ...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
47
+ ...(outputContract === undefined ? {} : outputContract),
48
+ }),
49
+ );
50
+ return hasher.digest("hex");
51
+ }
52
+
53
+ /**
54
+ * Retains Ask hash inputs for Definition-backed Cassette divergence diagnostics.
55
+ *
56
+ * `timeoutMs` remains excluded. Structured output schemas, explicit steer bounds,
57
+ * and extraction policy are retained because they participate in `askHash`.
58
+ */
59
+ export function askMarkerContext({
60
+ spawnOptions,
61
+ prompt,
62
+ ask,
63
+ outputContract = structuredOutputContract(ask),
64
+ }: AskHashOptions): AskMarkerContext {
65
+ const { name: _name, worktree: _worktree, ...spawn } = spawnOptions;
66
+ return {
67
+ prompt,
68
+ spawn: { ...spawn, ...(_worktree === true ? { worktree: true } : {}) },
69
+ ask: {
70
+ ...(ask?.maxTurns === undefined ? {} : { maxTurns: ask.maxTurns }),
71
+ ...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
72
+ ...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
73
+ ...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
74
+ ...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
75
+ ...(outputContract === undefined ? {} : outputContract),
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Creates structured identity only when the Ask explicitly carries a schema. */
81
+ export function structuredOutputContract(
82
+ ask: EffectiveAskOptions | undefined,
83
+ ): AskOutputContract | undefined {
84
+ if (ask === undefined || !("outputSchema" in ask)) return undefined;
85
+ return createAskOutputContract(ask.outputSchema, ask.maxSteers);
86
+ }
@@ -0,0 +1,189 @@
1
+ import type { AskLimitKind, AskLimitOutcome } from "./errors.ts";
2
+ import type { Frame } from "./transport.ts";
3
+ import type { AskOptions } from "./types.ts";
4
+
5
+ /** The message sent when an Ask uses its allotted soft budget. */
6
+ export const DEFAULT_WRAP_UP_PROMPT = "Please wrap up now and provide your final answer.";
7
+ /** Fixed time allowed to conclude after a duration limit trips. */
8
+ export const ASK_LIMIT_DURATION_GRACE_MS = 5_000;
9
+
10
+ export interface AskLimitOptions {
11
+ readonly ask: AskOptions;
12
+ readonly command: (frame: Frame) => Promise<boolean>;
13
+ readonly durationGraceMs?: number;
14
+ readonly now?: () => number;
15
+ }
16
+
17
+ /**
18
+ * Per-Ask soft-limit controller.
19
+ *
20
+ * A budget trips at equality. The triggering turn is allowed to finish and one
21
+ * later `turn_start` is grace, because pi delivers steering between turns.
22
+ */
23
+ export class AskLimit {
24
+ readonly #ask: AskOptions;
25
+ readonly #command: (frame: Frame) => Promise<boolean>;
26
+ readonly #durationGraceMs: number;
27
+ readonly #now: () => number;
28
+ readonly #failure: Promise<string>;
29
+ #rejectFailure: (reason: string) => void = () => {};
30
+ #startedAt = 0;
31
+ #monitoring = false;
32
+ #completed = false;
33
+ #correctingOutput = false;
34
+ #turns = 0;
35
+ #toolCalls = 0;
36
+ #trip: AskLimitOutcome | null = null;
37
+ #turnsAtSteer = 0;
38
+ #aborted = false;
39
+ #durationTimer: ReturnType<typeof setTimeout> | undefined;
40
+ #graceTimer: ReturnType<typeof setTimeout> | undefined;
41
+
42
+ constructor(options: AskLimitOptions) {
43
+ this.#ask = options.ask;
44
+ this.#command = options.command;
45
+ this.#durationGraceMs = options.durationGraceMs ?? ASK_LIMIT_DURATION_GRACE_MS;
46
+ this.#now = options.now ?? Date.now;
47
+ this.#failure = new Promise<string>((_resolve, reject) => {
48
+ this.#rejectFailure = reject;
49
+ });
50
+ // Commands can fail before the Ask starts awaiting this race.
51
+ void this.#failure.catch(() => {});
52
+ }
53
+
54
+ /** Starts wall-clock accounting immediately before the prompt command is sent. */
55
+ start(): void {
56
+ this.#startedAt = this.#now();
57
+ if (this.#ask.maxDurationMs === undefined) return;
58
+ this.#durationTimer = setTimeout(() => {
59
+ this.#tripLimit("durationMs", this.#now() - this.#startedAt);
60
+ }, this.#ask.maxDurationMs);
61
+ }
62
+
63
+ /** Observes frames without changing their interpretation for the Ask itself. */
64
+ observe(frame: Frame): void {
65
+ if (this.#completed) return;
66
+ if (!this.#monitoring) {
67
+ if (frame.type === "agent_start") this.#monitoring = true;
68
+ return;
69
+ }
70
+ if (this.#correctingOutput) return;
71
+ if (frame.type === "turn_start") {
72
+ this.#turns += 1;
73
+ if (this.#trip !== null) {
74
+ if (this.#turns > this.#turnsAtSteer + 1) this.#abort();
75
+ } else if (this.#ask.maxTurns !== undefined && this.#turns >= this.#ask.maxTurns) {
76
+ this.#tripLimit("turns", this.#turns);
77
+ }
78
+ return;
79
+ }
80
+ if (
81
+ frame.type === "tool_execution_start" &&
82
+ this.#trip === null &&
83
+ this.#ask.maxToolCalls !== undefined
84
+ ) {
85
+ this.#toolCalls += 1;
86
+ if (this.#toolCalls >= this.#ask.maxToolCalls) this.#tripLimit("toolCalls", this.#toolCalls);
87
+ return;
88
+ }
89
+ if (frame.type === "tool_execution_start") this.#toolCalls += 1;
90
+ }
91
+
92
+ /**
93
+ * Enters one output-contract correction effort when no limit is wrapping up.
94
+ *
95
+ * Returns false after a limit has tripped, so callers never reopen its grace
96
+ * window with a validator steer.
97
+ */
98
+ enterOutputCorrection(): boolean {
99
+ if (this.#completed || this.#trip !== null) return false;
100
+ this.#correctingOutput = true;
101
+ return true;
102
+ }
103
+
104
+ /** Leaves an output-contract correction effort after its text was retrieved. */
105
+ leaveOutputCorrection(): void {
106
+ this.#correctingOutput = false;
107
+ }
108
+
109
+ /** Cancels timers when the whole logical Ask reaches a terminal result. */
110
+ settled(): void {
111
+ if (this.#completed) return;
112
+ this.#completed = true;
113
+ this.#correctingOutput = false;
114
+ clearTimeout(this.#durationTimer);
115
+ clearTimeout(this.#graceTimer);
116
+ this.#durationTimer = undefined;
117
+ this.#graceTimer = undefined;
118
+ }
119
+
120
+ /** Cancels timers on every other Ask exit path. Idempotent. */
121
+ cleanup(): void {
122
+ this.settled();
123
+ }
124
+
125
+ /** Rejects when a steer or abort RPC is refused. */
126
+ get failed(): Promise<never> {
127
+ return this.#failure.then(
128
+ () => {
129
+ throw new Error("unreachable");
130
+ },
131
+ (error: unknown) => Promise.reject(error),
132
+ );
133
+ }
134
+
135
+ /** True once a limit has sent its wrap-up steer and owns the grace window. */
136
+ get tripped(): boolean {
137
+ return this.#trip !== null;
138
+ }
139
+
140
+ /** Present only after yaag itself has sent the limit abort command. */
141
+ get abortOutcome(): AskLimitOutcome | null {
142
+ return this.#aborted ? this.#trip : null;
143
+ }
144
+
145
+ #tripLimit(kind: AskLimitKind, count: number): void {
146
+ if (this.#completed || this.#trip !== null) return;
147
+ this.#trip = { kind, count };
148
+ this.#turnsAtSteer = this.#turns;
149
+ void this.#send({ type: "steer", message: this.#ask.wrapUpPrompt ?? DEFAULT_WRAP_UP_PROMPT });
150
+ if (kind === "durationMs") {
151
+ this.#graceTimer = setTimeout(() => this.#abort(), this.#durationGraceMs);
152
+ }
153
+ }
154
+
155
+ #abort(): void {
156
+ if (this.#completed || this.#aborted || this.#trip === null) return;
157
+ void this.#sendAbort();
158
+ }
159
+
160
+ async #sendAbort(): Promise<void> {
161
+ if (this.#completed || this.#aborted || this.#trip === null) return;
162
+ // `command()` writes synchronously; latch before its response can race settlement.
163
+ this.#aborted = true;
164
+ try {
165
+ if (!(await this.#command({ type: "abort" }))) {
166
+ this.#rejectFailure("limit control command was rejected");
167
+ return;
168
+ }
169
+ } catch (error) {
170
+ this.#rejectFailure(
171
+ error instanceof Error
172
+ ? `limit control command failed: ${error.message}`
173
+ : "limit control command failed",
174
+ );
175
+ }
176
+ }
177
+
178
+ async #send(frame: Frame): Promise<void> {
179
+ try {
180
+ if (!(await this.#command(frame))) this.#rejectFailure("limit control command was rejected");
181
+ } catch (error) {
182
+ this.#rejectFailure(
183
+ error instanceof Error
184
+ ? `limit control command failed: ${error.message}`
185
+ : "limit control command failed",
186
+ );
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,69 @@
1
+ import type { TSchema } from "typebox";
2
+ import {
3
+ formatAskOutputCorrection,
4
+ invalidAskOutputError,
5
+ validateAskOutput,
6
+ } from "./ask-output.ts";
7
+
8
+ /** The per-Ask bound when a structured Ask omits `maxSteers`. */
9
+ export const DEFAULT_MAX_STEERS = 3;
10
+
11
+ export interface AskOutputSteeringOptions {
12
+ readonly agent: string;
13
+ readonly schema: TSchema;
14
+ readonly maxSteers: number | undefined;
15
+ /** Atomically enters a correction effort, or false after a limit starts wrap-up. */
16
+ readonly beginCorrection: () => boolean;
17
+ /** True once a soft limit has sent its wrap-up steer. */
18
+ readonly wrappingUp: () => boolean;
19
+ /** Re-arms settlement tracking, sends the steer, and returns its settled text. */
20
+ readonly correct: (message: string) => Promise<string>;
21
+ /** Re-arms settlement tracking, sends abort, and waits for its settlement. */
22
+ readonly abort: () => Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Validates structured output and corrects it without creating another logical Ask.
27
+ *
28
+ * Control-command failures propagate from `correct` or `abort` as Agent failures,
29
+ * rather than being reported as invalid-output exhaustion.
30
+ */
31
+ export class AskOutputSteering {
32
+ readonly #agent: string;
33
+ readonly #schema: TSchema;
34
+ readonly #maxSteers: number;
35
+ readonly #beginCorrection: () => boolean;
36
+ readonly #wrappingUp: () => boolean;
37
+ readonly #correct: (message: string) => Promise<string>;
38
+ readonly #abort: () => Promise<void>;
39
+
40
+ constructor(options: AskOutputSteeringOptions) {
41
+ this.#agent = options.agent;
42
+ this.#schema = options.schema;
43
+ this.#maxSteers = options.maxSteers ?? DEFAULT_MAX_STEERS;
44
+ this.#beginCorrection = options.beginCorrection;
45
+ this.#wrappingUp = options.wrappingUp;
46
+ this.#correct = options.correct;
47
+ this.#abort = options.abort;
48
+ }
49
+
50
+ /** Resolves a valid parsed value or rejects after the abort settlement on exhaustion. */
51
+ async resolve(text: string): Promise<unknown> {
52
+ let efforts = 0;
53
+ let result = validateAskOutput(text, this.#schema);
54
+ while (!result.ok && efforts < this.#maxSteers) {
55
+ if (!this.#beginCorrection()) throw this.#invalidAfterWrapUp(result.errors);
56
+ efforts += 1;
57
+ text = await this.#correct(formatAskOutputCorrection(result.errors));
58
+ result = validateAskOutput(text, this.#schema);
59
+ }
60
+ if (result.ok) return result.value;
61
+ if (this.#wrappingUp()) throw this.#invalidAfterWrapUp(result.errors);
62
+ await this.#abort();
63
+ throw invalidAskOutputError(this.#agent, { steeringEfforts: efforts, errors: result.errors });
64
+ }
65
+
66
+ #invalidAfterWrapUp(errors: readonly string[]): Error {
67
+ return invalidAskOutputError(this.#agent, { steeringEfforts: 0, errors });
68
+ }
69
+ }