@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.
- package/package.json +25 -0
- package/src/agent-names.ts +20 -0
- package/src/agent-usage.ts +72 -0
- package/src/agent.ts +130 -0
- package/src/args-validation.ts +11 -0
- package/src/ask-activity.ts +84 -0
- package/src/ask-contract-identity.ts +96 -0
- package/src/ask-exchange-events.ts +60 -0
- package/src/ask-exchange-options.ts +32 -0
- package/src/ask-exchange.ts +291 -0
- package/src/ask-hash.ts +86 -0
- package/src/ask-limit.ts +189 -0
- package/src/ask-output-steering.ts +69 -0
- package/src/ask-output-tail.ts +166 -0
- package/src/ask-output.ts +109 -0
- package/src/ask-settlement.ts +37 -0
- package/src/ask-turn.ts +70 -0
- package/src/cassette-loader.ts +131 -0
- package/src/cassette-publish.ts +55 -0
- package/src/cassette-replay.ts +178 -0
- package/src/cassette-schema.ts +152 -0
- package/src/cassette.ts +275 -0
- package/src/checkpoint-dir.ts +89 -0
- package/src/connection.ts +123 -0
- package/src/define-agent.ts +83 -0
- package/src/define-run.ts +69 -0
- package/src/errors.ts +115 -0
- package/src/events.ts +143 -0
- package/src/extension-package.ts +88 -0
- package/src/extension-paths.ts +66 -0
- package/src/extension-source.ts +60 -0
- package/src/fake-transport.ts +240 -0
- package/src/frame-gap.ts +41 -0
- package/src/frame-queue.ts +52 -0
- package/src/git-facts.ts +32 -0
- package/src/idle-watch.ts +154 -0
- package/src/index.ts +96 -0
- package/src/jsonl.ts +42 -0
- package/src/live-transport.ts +210 -0
- package/src/node-decoder-subagent.ts +67 -0
- package/src/node-decoder-workflow.ts +74 -0
- package/src/node-decoder.ts +23 -0
- package/src/node-decoders.ts +9 -0
- package/src/node-details.ts +70 -0
- package/src/node-path.ts +36 -0
- package/src/node-tracker.ts +143 -0
- package/src/pi-state.ts +108 -0
- package/src/prompt-gist.ts +13 -0
- package/src/prompt.ts +80 -0
- package/src/reap.ts +59 -0
- package/src/recording-transport.ts +97 -0
- package/src/replay-divergence.ts +155 -0
- package/src/replay-transport.ts +72 -0
- package/src/resume-preconditions.ts +59 -0
- package/src/resume-transport.ts +165 -0
- package/src/run-checkpoint.ts +93 -0
- package/src/run-context.ts +19 -0
- package/src/run.ts +274 -0
- package/src/skill-probe.ts +247 -0
- package/src/skill-restriction-transport.ts +76 -0
- package/src/spawn.ts +241 -0
- package/src/summary-agent.ts +310 -0
- package/src/summary-nodes.ts +77 -0
- package/src/summary.ts +213 -0
- package/src/tool-probe-extension.ts +17 -0
- package/src/tool-probe.ts +141 -0
- package/src/transport.ts +178 -0
- package/src/types.ts +130 -0
- package/src/validation-errors.ts +70 -0
- package/src/wire-constants.ts +24 -0
- package/src/worktree-transport.ts +125 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses pi package specifiers (`npm:`, `git:`) used as extension declarations.
|
|
3
|
+
*
|
|
4
|
+
* A declaration that carries neither prefix is a filesystem path and is reported
|
|
5
|
+
* as `{ kind: "path" }` so the caller keeps its existing resolution behaviour.
|
|
6
|
+
*/
|
|
7
|
+
export type ExtensionSource =
|
|
8
|
+
| { readonly kind: "path" }
|
|
9
|
+
| { readonly kind: "npm"; readonly packageName: string }
|
|
10
|
+
| { readonly kind: "git"; readonly host: string; readonly owner: string; readonly repo: string };
|
|
11
|
+
|
|
12
|
+
/** An extension declared as a pi package specifier rather than a filesystem path. */
|
|
13
|
+
export type InstalledExtensionSource = Exclude<ExtensionSource, { kind: "path" }>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Classifies one extension declaration.
|
|
17
|
+
*
|
|
18
|
+
* Throws when an `npm:`/`git:` prefix is present but the remainder is not a
|
|
19
|
+
* package name or a `<host>/<owner>/<repo>` triple.
|
|
20
|
+
*/
|
|
21
|
+
export function parseExtensionSource(declaration: string): ExtensionSource {
|
|
22
|
+
if (declaration.startsWith("npm:")) {
|
|
23
|
+
return { kind: "npm", packageName: parsePackageName(declaration) };
|
|
24
|
+
}
|
|
25
|
+
if (declaration.startsWith("git:")) return parseGitSource(declaration);
|
|
26
|
+
return { kind: "path" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsePackageName(declaration: string): string {
|
|
30
|
+
const spec = declaration.slice("npm:".length);
|
|
31
|
+
if (spec === "") throw new Error(`extension "${declaration}" has no package name`);
|
|
32
|
+
const scoped = spec.startsWith("@");
|
|
33
|
+
const versionAt = spec.indexOf("@", scoped ? 1 : 0);
|
|
34
|
+
const name = versionAt === -1 ? spec : spec.slice(0, versionAt);
|
|
35
|
+
if (name === "" || (scoped && !name.includes("/"))) {
|
|
36
|
+
throw new Error(`extension "${declaration}" has no package name`);
|
|
37
|
+
}
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseGitSource(declaration: string): ExtensionSource {
|
|
42
|
+
const location = normalizeGitLocation(declaration.slice("git:".length));
|
|
43
|
+
const segments = location.split("/").filter((segment) => segment !== "");
|
|
44
|
+
if (segments.length < 3) {
|
|
45
|
+
throw new Error(`extension "${declaration}" is not a git <host>/<owner>/<repo> reference`);
|
|
46
|
+
}
|
|
47
|
+
const [host, owner, repo] = segments.slice(segments.length - 3);
|
|
48
|
+
if (host === undefined || owner === undefined || repo === undefined) {
|
|
49
|
+
throw new Error(`extension "${declaration}" is not a git <host>/<owner>/<repo> reference`);
|
|
50
|
+
}
|
|
51
|
+
return { kind: "git", host, owner, repo: repo.replace(/\.git$/, "") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeGitLocation(location: string): string {
|
|
55
|
+
// pi accepts the same repo as https://, ssh:// and scp-style `git@host:owner/repo`
|
|
56
|
+
// (docs/packages.md); all three land in the same ~/.pi/agent/git checkout.
|
|
57
|
+
const withoutScheme = location.replace(/^[a-z+]+:\/\//, "");
|
|
58
|
+
const withoutUser = withoutScheme.replace(/^[^@/]+@/, "");
|
|
59
|
+
return withoutUser.replace(":", "/");
|
|
60
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { DEFAULT_WRAP_UP_PROMPT } from "./ask-limit.ts";
|
|
2
|
+
import { FrameQueue } from "./frame-queue.ts";
|
|
3
|
+
import { parseFrame } from "./jsonl.ts";
|
|
4
|
+
import type { AgentStats, AgentTransport, AskMarker, Frame } from "./transport.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scripted frame playback for one prompt sent through a FakeTransport.
|
|
8
|
+
*
|
|
9
|
+
* Abort frames play only after that prompt's abort command.
|
|
10
|
+
*/
|
|
11
|
+
export interface FakePromptScript {
|
|
12
|
+
/** Frames the Agent emits in response to this prompt, in recorded order. */
|
|
13
|
+
readonly frames?: readonly Frame[];
|
|
14
|
+
/** Scripts selected in order by correction follow-up `prompt` commands. */
|
|
15
|
+
readonly corrections?: readonly FakePromptScript[];
|
|
16
|
+
/** Script selected by the configured or default soft-limit wrap-up steer, when provided. */
|
|
17
|
+
readonly wrapUp?: FakePromptScript;
|
|
18
|
+
/** Prompt that selects `wrapUp`; falls back to the runtime default when omitted. */
|
|
19
|
+
readonly wrapUpPrompt?: string;
|
|
20
|
+
/** Frames emitted after yaag sends an abort command. */
|
|
21
|
+
readonly abortFrames?: readonly Frame[];
|
|
22
|
+
/** Delay before each scripted frame, for clock-driven tests. */
|
|
23
|
+
readonly frameDelayMs?: number;
|
|
24
|
+
/** Delay before each abort-specific frame, for abort-settlement tests. */
|
|
25
|
+
readonly abortFrameDelayMs?: number;
|
|
26
|
+
/** Payload of `get_last_assistant_text`; null means the `{}` pi returns on failure. */
|
|
27
|
+
readonly lastText?: string | null;
|
|
28
|
+
/** Makes this script's prompt RPC response fail, before any frame playback. */
|
|
29
|
+
readonly promptError?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Configures a FakeTransport with one shorthand script or ordered per-prompt scripts.
|
|
34
|
+
*
|
|
35
|
+
* Control-command failures apply across the configured playback.
|
|
36
|
+
*/
|
|
37
|
+
export interface FakeTransportOptions extends FakePromptScript {
|
|
38
|
+
/** One script per Ask's initial prompt, used in order; preserves the single-script shorthand above. */
|
|
39
|
+
readonly scripts?: readonly FakePromptScript[];
|
|
40
|
+
/** Makes a steer RPC response fail without embedding a limit decision in playback. */
|
|
41
|
+
readonly steerError?: string;
|
|
42
|
+
/** Makes an abort RPC response fail without embedding a limit decision in playback. */
|
|
43
|
+
readonly abortError?: string;
|
|
44
|
+
readonly stats?: AgentStats;
|
|
45
|
+
/** pi can answer a command after `agent_settled` — it is last among events only. */
|
|
46
|
+
readonly promptResponse?: "immediate" | "after-settle";
|
|
47
|
+
/** Ends the frame stream once playback finishes, as a dying process would. */
|
|
48
|
+
readonly dieAfterFrames?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Test implementation of the transport seam that plays scripted recorded or synthetic frames.
|
|
53
|
+
*
|
|
54
|
+
* It encodes real pi (0.84.1) post-settlement semantics: an Ask's initial prompt
|
|
55
|
+
* selects the next top-level script, each later prompt within the same Ask selects
|
|
56
|
+
* the next correction script, and a non-wrap-up steer is acknowledged but only
|
|
57
|
+
* queued (`queue_update`) — it never plays frames or settles.
|
|
58
|
+
* It never spawns or closes a real Agent process.
|
|
59
|
+
*/
|
|
60
|
+
export class FakeTransport implements AgentTransport {
|
|
61
|
+
readonly model = "test/model";
|
|
62
|
+
/** Everything the runtime wrote, in order. */
|
|
63
|
+
readonly sent: Frame[] = [];
|
|
64
|
+
readonly asks: AskMarker[] = [];
|
|
65
|
+
readonly #queue = new FrameQueue();
|
|
66
|
+
readonly #options: FakeTransportOptions;
|
|
67
|
+
#closed = false;
|
|
68
|
+
#closeCalls = 0;
|
|
69
|
+
#closing: Promise<AgentStats> | null = null;
|
|
70
|
+
#scriptIndex = 0;
|
|
71
|
+
// A fresh Agent's first prompt is always an Ask's initial prompt, even when a
|
|
72
|
+
// wrapping transport hides beginAsk from this fake.
|
|
73
|
+
#initialPromptPending = true;
|
|
74
|
+
#correctionIndex = 0;
|
|
75
|
+
#corrections: readonly FakePromptScript[] = [];
|
|
76
|
+
#currentScript: FakePromptScript | null = null;
|
|
77
|
+
#playback: Promise<void> = Promise.resolve();
|
|
78
|
+
|
|
79
|
+
constructor(options: FakeTransportOptions = {}) {
|
|
80
|
+
this.#options = options;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
send(frame: Frame): void {
|
|
84
|
+
this.sent.push(frame);
|
|
85
|
+
if (frame.type === "prompt") void this.#answerPrompt(frame);
|
|
86
|
+
if (frame.type === "steer") void this.#answerSteer(frame);
|
|
87
|
+
if (frame.type === "abort") void this.#answerAbort(frame);
|
|
88
|
+
if (frame.type === "get_last_assistant_text") this.#answerLastText(frame);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
frames(): AsyncIterable<Frame> {
|
|
92
|
+
return this.#queue.frames();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beginAsk(marker: AskMarker): undefined {
|
|
96
|
+
this.asks.push(marker);
|
|
97
|
+
this.#initialPromptPending = true;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
finishAsk(): void {
|
|
102
|
+
// This live test transport does not persist Ask outcomes.
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True once the runtime shut this Agent down. */
|
|
106
|
+
get closed(): boolean {
|
|
107
|
+
return this.#closed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Number of times this fake actually began close work. */
|
|
111
|
+
get closeCalls(): number {
|
|
112
|
+
return this.#closeCalls;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
close(): Promise<AgentStats> {
|
|
116
|
+
if (this.#closing === null) {
|
|
117
|
+
this.#closeCalls += 1;
|
|
118
|
+
this.#closed = true;
|
|
119
|
+
this.#queue.end();
|
|
120
|
+
this.#closing = Promise.resolve(
|
|
121
|
+
this.#options.stats ?? {
|
|
122
|
+
tokens: { input: 30, output: 12, cacheRead: 0, cacheWrite: 0, total: 42 },
|
|
123
|
+
cost: 0.001,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return this.#closing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Simulates the process dying: the frame stream just ends. */
|
|
131
|
+
die(): void {
|
|
132
|
+
this.#queue.end();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async #answerPrompt(frame: Frame): Promise<void> {
|
|
136
|
+
const script = this.#selectPromptScript();
|
|
137
|
+
const error = script.promptError ?? this.#options.promptError;
|
|
138
|
+
const response = {
|
|
139
|
+
type: "response",
|
|
140
|
+
command: "prompt",
|
|
141
|
+
id: frame.id,
|
|
142
|
+
success: error === undefined,
|
|
143
|
+
...(error === undefined ? {} : { error }),
|
|
144
|
+
};
|
|
145
|
+
if (this.#options.promptResponse !== "after-settle") this.#queue.push(response);
|
|
146
|
+
await this.#enqueue(script.frames, script.frameDelayMs);
|
|
147
|
+
if (this.#options.promptResponse === "after-settle") this.#queue.push(response);
|
|
148
|
+
if (this.#options.dieAfterFrames) this.#queue.end();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An Ask's initial prompt takes the next top-level script; later prompts are corrections. */
|
|
152
|
+
#selectPromptScript(): FakePromptScript {
|
|
153
|
+
if (this.#initialPromptPending) {
|
|
154
|
+
this.#initialPromptPending = false;
|
|
155
|
+
const script = this.#options.scripts?.[this.#scriptIndex++] ?? this.#options;
|
|
156
|
+
this.#currentScript = script;
|
|
157
|
+
this.#correctionIndex = 0;
|
|
158
|
+
this.#corrections = script.corrections ?? [];
|
|
159
|
+
return script;
|
|
160
|
+
}
|
|
161
|
+
const script = this.#corrections[this.#correctionIndex++];
|
|
162
|
+
if (script !== undefined) this.#currentScript = script;
|
|
163
|
+
return script ?? {};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #answerSteer(frame: Frame): Promise<void> {
|
|
167
|
+
this.#answerControl(frame, this.#options.steerError);
|
|
168
|
+
if (this.#options.steerError !== undefined) return;
|
|
169
|
+
if (this.#isWrapUp(frame)) {
|
|
170
|
+
const script = this.#currentScript?.wrapUp;
|
|
171
|
+
if (script === undefined) return;
|
|
172
|
+
this.#currentScript = script;
|
|
173
|
+
await this.#enqueue(script.frames, script.frameDelayMs);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// pi 0.84.1 parks a steer sent between turns: acknowledged, queued, never played.
|
|
177
|
+
this.#queue.push({ type: "queue_update" });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#isWrapUp(frame: Frame): boolean {
|
|
181
|
+
return frame.message === (this.#currentScript?.wrapUpPrompt ?? DEFAULT_WRAP_UP_PROMPT);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#answerControl(frame: Frame, error?: string): void {
|
|
185
|
+
this.#queue.push({
|
|
186
|
+
type: "response",
|
|
187
|
+
command: frame.type,
|
|
188
|
+
id: frame.id,
|
|
189
|
+
success: error === undefined,
|
|
190
|
+
...(error === undefined ? {} : { error }),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #answerAbort(frame: Frame): Promise<void> {
|
|
195
|
+
this.#answerControl(frame, this.#options.abortError);
|
|
196
|
+
await this.#enqueue(
|
|
197
|
+
this.#currentScript?.abortFrames,
|
|
198
|
+
this.#currentScript?.abortFrameDelayMs ?? this.#currentScript?.frameDelayMs,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#enqueue(frames: readonly Frame[] | undefined, delayMs?: number): Promise<void> {
|
|
203
|
+
const playback = this.#playback.then(() => this.#play(frames, delayMs));
|
|
204
|
+
this.#playback = playback.catch(() => {});
|
|
205
|
+
return playback;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #play(frames: readonly Frame[] | undefined, delayMs?: number): Promise<void> {
|
|
209
|
+
for (const recorded of frames ?? []) {
|
|
210
|
+
if (delayMs === undefined) await Promise.resolve();
|
|
211
|
+
else await Bun.sleep(delayMs);
|
|
212
|
+
if (this.#closed) return;
|
|
213
|
+
this.#queue.push(recorded);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#answerLastText(frame: Frame): void {
|
|
218
|
+
const text = this.#currentScript?.lastText ?? this.#options.lastText;
|
|
219
|
+
this.#queue.push({
|
|
220
|
+
type: "response",
|
|
221
|
+
command: "get_last_assistant_text",
|
|
222
|
+
id: frame.id,
|
|
223
|
+
success: true,
|
|
224
|
+
data: text === null || text === undefined ? {} : { text },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Loads and parses a named JSONL frame fixture from the runtime fixtures directory.
|
|
231
|
+
*
|
|
232
|
+
* Rejects when the fixture cannot be read or contains an invalid frame.
|
|
233
|
+
*/
|
|
234
|
+
export async function recordedFrames(name: string): Promise<Frame[]> {
|
|
235
|
+
const text = await Bun.file(`${import.meta.dir}/fixtures/${name}.jsonl`).text();
|
|
236
|
+
return text
|
|
237
|
+
.split("\n")
|
|
238
|
+
.map(parseFrame)
|
|
239
|
+
.filter((frame): frame is Frame => frame !== null);
|
|
240
|
+
}
|
package/src/frame-gap.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-Ask frame-gap telemetry: the largest silence between two consecutive
|
|
3
|
+
* Agent frames (ADR-0020 — measure before enforcing idle thresholds).
|
|
4
|
+
*
|
|
5
|
+
* The first gap is measured from prompt send (`start()`) to the first frame;
|
|
6
|
+
* later gaps are inter-arrival. Live-only: Cassette playback creates no tracker,
|
|
7
|
+
* because replayed frames arrive instantly and their gaps mean nothing.
|
|
8
|
+
*/
|
|
9
|
+
export interface FrameGapOptions {
|
|
10
|
+
/** Injectable clock, so tests need no real timers. Defaults to `Date.now`. */
|
|
11
|
+
readonly now?: () => number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Tracks the maximum inter-frame gap of one Ask. No per-frame allocation. */
|
|
15
|
+
export class FrameGapTracker {
|
|
16
|
+
readonly #now: () => number;
|
|
17
|
+
#lastAt = 0;
|
|
18
|
+
#maxGapMs = 0;
|
|
19
|
+
|
|
20
|
+
constructor(options: FrameGapOptions = {}) {
|
|
21
|
+
this.#now = options.now ?? Date.now;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Marks prompt send. Call once, before the prompt command. */
|
|
25
|
+
start(): void {
|
|
26
|
+
this.#lastAt = this.#now();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Records one frame arrival, widening the maximum gap if this one is larger. */
|
|
30
|
+
observe(): void {
|
|
31
|
+
const at = this.#now();
|
|
32
|
+
const gap = at - this.#lastAt;
|
|
33
|
+
if (gap > this.#maxGapMs) this.#maxGapMs = gap;
|
|
34
|
+
this.#lastAt = at;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The largest gap observed so far, in milliseconds; 0 when no frame arrived. */
|
|
38
|
+
get maxGapMs(): number {
|
|
39
|
+
return this.#maxGapMs;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Frame } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single-consumer queue turning pushed frames into an async iterable.
|
|
5
|
+
*
|
|
6
|
+
* The live transport reads its child's stdout once — intercepting the responses
|
|
7
|
+
* to its own commands — and republishes the rest here, so the layer above sees
|
|
8
|
+
* one ordered stream regardless of when it starts iterating.
|
|
9
|
+
*/
|
|
10
|
+
export class FrameQueue {
|
|
11
|
+
#buffered: Frame[] = [];
|
|
12
|
+
#waiting: ((frame: Frame | null) => void) | null = null;
|
|
13
|
+
#ended = false;
|
|
14
|
+
|
|
15
|
+
push(frame: Frame): void {
|
|
16
|
+
if (this.#ended) return;
|
|
17
|
+
const waiter = this.#waiting;
|
|
18
|
+
if (waiter) {
|
|
19
|
+
this.#waiting = null;
|
|
20
|
+
waiter(frame);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.#buffered.push(frame);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Ends the stream; the consumer's iteration finishes once drained. */
|
|
27
|
+
end(): void {
|
|
28
|
+
if (this.#ended) return;
|
|
29
|
+
this.#ended = true;
|
|
30
|
+
const waiter = this.#waiting;
|
|
31
|
+
if (waiter) {
|
|
32
|
+
this.#waiting = null;
|
|
33
|
+
waiter(null);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async *frames(): AsyncGenerator<Frame> {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const buffered = this.#buffered.shift();
|
|
40
|
+
if (buffered) {
|
|
41
|
+
yield buffered;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (this.#ended) return;
|
|
45
|
+
const next = await new Promise<Frame | null>((resolve) => {
|
|
46
|
+
this.#waiting = resolve;
|
|
47
|
+
});
|
|
48
|
+
if (next === null) return;
|
|
49
|
+
yield next;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/git-facts.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CassetteGit } from "./cassette.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Observes the branch and commit at a cwd without changing repository state.
|
|
5
|
+
*
|
|
6
|
+
* Returns undefined when Git cannot describe a checked-out work tree.
|
|
7
|
+
*/
|
|
8
|
+
export async function readGitFacts(cwd: string): Promise<CassetteGit | undefined> {
|
|
9
|
+
const inWorkTree = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
10
|
+
if (inWorkTree !== "true") return undefined;
|
|
11
|
+
const [branch, head] = await Promise.all([
|
|
12
|
+
git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
13
|
+
git(cwd, ["rev-parse", "HEAD"]),
|
|
14
|
+
]);
|
|
15
|
+
if (!branch || !head) return undefined;
|
|
16
|
+
return { branch, head };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function git(cwd: string, args: readonly string[]): Promise<string | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
const process = Bun.spawn({
|
|
22
|
+
cmd: ["git", "-C", cwd, ...args],
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "ignore",
|
|
25
|
+
});
|
|
26
|
+
const [code, output] = await Promise.all([process.exited, new Response(process.stdout).text()]);
|
|
27
|
+
const value = output.trim();
|
|
28
|
+
return code === 0 && value.length > 0 && !value.includes("\n") ? value : undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { AskStalledOutcome } from "./errors.ts";
|
|
2
|
+
import type { Frame } from "./transport.ts";
|
|
3
|
+
|
|
4
|
+
/** Fixed time allowed for `agent_settled` to arrive after the idle abort. */
|
|
5
|
+
export const IDLE_ABORT_SETTLE_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
export interface IdleWatchOptions {
|
|
8
|
+
/** Maximum silence, in milliseconds, before escalation starts. */
|
|
9
|
+
readonly idleMs: number;
|
|
10
|
+
readonly command: (frame: Frame) => Promise<boolean>;
|
|
11
|
+
/** Bounded wait for `agent_settled` after the abort; defaults to 5s. */
|
|
12
|
+
readonly abortSettleMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Rejection carried by {@link IdleWatch.killed} when abort produced nothing. */
|
|
16
|
+
export class IdleKillSignal extends Error {
|
|
17
|
+
readonly outcome: AskStalledOutcome;
|
|
18
|
+
|
|
19
|
+
constructor(outcome: AskStalledOutcome) {
|
|
20
|
+
super(`no frame for ${outcome.idleMs}ms and abort did not settle the Agent`);
|
|
21
|
+
this.name = "IdleKillSignal";
|
|
22
|
+
this.outcome = outcome;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-Ask silence watchdog, a sibling of `AskLimit` (ADR-0020).
|
|
28
|
+
*
|
|
29
|
+
* The timer starts at prompt send and resets on every frame. On expiry it
|
|
30
|
+
* sends `abort` and nothing else — a queued `steer` would be delivered as a new
|
|
31
|
+
* turn the instant abort ends the hung one, and `agent_settled` would never
|
|
32
|
+
* arrive.
|
|
33
|
+
*/
|
|
34
|
+
export class IdleWatch {
|
|
35
|
+
readonly #idleMs: number;
|
|
36
|
+
readonly #command: (frame: Frame) => Promise<boolean>;
|
|
37
|
+
readonly #abortSettleMs: number;
|
|
38
|
+
readonly #failure: Promise<never>;
|
|
39
|
+
readonly #kill: Promise<never>;
|
|
40
|
+
#rejectFailure: (reason: unknown) => void = () => {};
|
|
41
|
+
#rejectKill: (reason: unknown) => void = () => {};
|
|
42
|
+
#idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
#killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
#tripped = false;
|
|
45
|
+
#done = false;
|
|
46
|
+
#result: AskStalledOutcome | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(options: IdleWatchOptions) {
|
|
49
|
+
this.#idleMs = options.idleMs;
|
|
50
|
+
this.#command = options.command;
|
|
51
|
+
this.#abortSettleMs = options.abortSettleMs ?? IDLE_ABORT_SETTLE_MS;
|
|
52
|
+
this.#failure = new Promise<never>((_resolve, reject) => {
|
|
53
|
+
this.#rejectFailure = reject;
|
|
54
|
+
});
|
|
55
|
+
this.#kill = new Promise<never>((_resolve, reject) => {
|
|
56
|
+
this.#rejectKill = reject;
|
|
57
|
+
});
|
|
58
|
+
// Either race may settle before the Ask starts awaiting them.
|
|
59
|
+
void this.#failure.catch(() => {});
|
|
60
|
+
void this.#kill.catch(() => {});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Arms the idle timer immediately before the prompt command is sent. */
|
|
64
|
+
start(): void {
|
|
65
|
+
this.#arm();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resets the silence timer; after escalation, watches for `agent_settled`. */
|
|
69
|
+
observe(frame: Frame): void {
|
|
70
|
+
if (this.#done) return;
|
|
71
|
+
if (this.#tripped) {
|
|
72
|
+
if (frame.type === "agent_settled") this.#finish(false);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.#arm();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Cancels the watch once the Ask settles. Idempotent. */
|
|
79
|
+
settled(): void {
|
|
80
|
+
if (this.#done) return;
|
|
81
|
+
if (this.#tripped) {
|
|
82
|
+
this.#finish(false);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.#done = true;
|
|
86
|
+
this.cleanup();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cancels timers on every other Ask exit path. Idempotent. */
|
|
90
|
+
cleanup(): void {
|
|
91
|
+
clearTimeout(this.#idleTimer);
|
|
92
|
+
clearTimeout(this.#killTimer);
|
|
93
|
+
this.#idleTimer = undefined;
|
|
94
|
+
this.#killTimer = undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True once the silence budget expired and escalation began. */
|
|
98
|
+
get tripped(): boolean {
|
|
99
|
+
return this.#tripped;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The stalled outcome, once escalation resolved either way. */
|
|
103
|
+
get result(): AskStalledOutcome | null {
|
|
104
|
+
return this.#result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Rejects when the abort RPC is refused, mirroring `AskLimit.failed`. */
|
|
108
|
+
get failed(): Promise<never> {
|
|
109
|
+
return this.#failure;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Rejects with an {@link IdleKillSignal} only on the destructive path. */
|
|
113
|
+
get killed(): Promise<never> {
|
|
114
|
+
return this.#kill;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#arm(): void {
|
|
118
|
+
clearTimeout(this.#idleTimer);
|
|
119
|
+
this.#idleTimer = setTimeout(() => this.#trip(), this.#idleMs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#trip(): void {
|
|
123
|
+
if (this.#done || this.#tripped) return;
|
|
124
|
+
this.#tripped = true;
|
|
125
|
+
clearTimeout(this.#idleTimer);
|
|
126
|
+
this.#idleTimer = undefined;
|
|
127
|
+
this.#killTimer = setTimeout(() => this.#finish(true), this.#abortSettleMs);
|
|
128
|
+
void this.#sendAbort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#finish(destructive: boolean): void {
|
|
132
|
+
if (this.#done) return;
|
|
133
|
+
this.#done = true;
|
|
134
|
+
this.#result = { idleMs: this.#idleMs, destructive };
|
|
135
|
+
this.cleanup();
|
|
136
|
+
if (destructive) this.#rejectKill(new IdleKillSignal(this.#result));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #sendAbort(): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
if (!(await this.#command({ type: "abort" }))) {
|
|
142
|
+
this.#rejectFailure(new Error("idle abort command was rejected"));
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.#rejectFailure(
|
|
146
|
+
new Error(
|
|
147
|
+
error instanceof Error
|
|
148
|
+
? `idle abort command failed: ${error.message}`
|
|
149
|
+
: "idle abort command failed",
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Cassette,
|
|
3
|
+
CassetteAgent,
|
|
4
|
+
CassetteArtifact,
|
|
5
|
+
CassetteAsk,
|
|
6
|
+
CassetteGit,
|
|
7
|
+
CassetteRun,
|
|
8
|
+
CassetteSink,
|
|
9
|
+
CassetteSpawn,
|
|
10
|
+
} from "./cassette.ts";
|
|
11
|
+
export { CASSETTE_VERSION } from "./cassette.ts";
|
|
12
|
+
export { loadCassette } from "./cassette-loader.ts";
|
|
13
|
+
export type { AgentConfig, AgentDefinition } from "./define-agent.ts";
|
|
14
|
+
export { agentDefinitionConfig, defineAgent, isAgentDefinition } from "./define-agent.ts";
|
|
15
|
+
export type { OrchestrationProgram, ProgramDefinition } from "./define-run.ts";
|
|
16
|
+
export { defineRun, isOrchestrationProgram, programDefinition } from "./define-run.ts";
|
|
17
|
+
export type {
|
|
18
|
+
AskInvalidOutputOutcome,
|
|
19
|
+
AskLimitKind,
|
|
20
|
+
AskLimitOutcome,
|
|
21
|
+
AskStalledOutcome,
|
|
22
|
+
YaagErrorCode,
|
|
23
|
+
} from "./errors.ts";
|
|
24
|
+
export { isYaagError, YaagError } from "./errors.ts";
|
|
25
|
+
export type {
|
|
26
|
+
AgentActivity,
|
|
27
|
+
AskOutputChannel,
|
|
28
|
+
EventSink,
|
|
29
|
+
LifecycleEvent,
|
|
30
|
+
LifecycleEventBody,
|
|
31
|
+
NodeState,
|
|
32
|
+
NodeUsage,
|
|
33
|
+
StampedEventSink,
|
|
34
|
+
} from "./events.ts";
|
|
35
|
+
export type { DecodedNode, NodeDecoder } from "./node-decoder.ts";
|
|
36
|
+
export { DEFAULT_NODE_DECODERS } from "./node-decoders.ts";
|
|
37
|
+
export type { NodePath } from "./node-path.ts";
|
|
38
|
+
export { agentAskPath, childPath, sanitizeNodeName } from "./node-path.ts";
|
|
39
|
+
export type { NodeSnapshot } from "./node-tracker.ts";
|
|
40
|
+
export { NodeTracker } from "./node-tracker.ts";
|
|
41
|
+
export { prompt } from "./prompt.ts";
|
|
42
|
+
export { promptGist } from "./prompt-gist.ts";
|
|
43
|
+
export type { ReapPath, ReapTarget } from "./reap.ts";
|
|
44
|
+
export { reap } from "./reap.ts";
|
|
45
|
+
export { recordingTransport } from "./recording-transport.ts";
|
|
46
|
+
export { replayTransport } from "./replay-transport.ts";
|
|
47
|
+
export { resumeTransport } from "./resume-transport.ts";
|
|
48
|
+
export type { RunOptions } from "./run.ts";
|
|
49
|
+
export { executeRun } from "./run.ts";
|
|
50
|
+
export type { RunContext } from "./run-context.ts";
|
|
51
|
+
export type { DiscoveredSkill, SkillProbeFactory } from "./skill-probe.ts";
|
|
52
|
+
export type {
|
|
53
|
+
AgentInfo,
|
|
54
|
+
AgentState,
|
|
55
|
+
AskingAgentInfo,
|
|
56
|
+
EndedRunSummary,
|
|
57
|
+
ExitedAgentInfo,
|
|
58
|
+
IdleAgentInfo,
|
|
59
|
+
NodeInfo,
|
|
60
|
+
RunningRunSummary,
|
|
61
|
+
RunOutcome,
|
|
62
|
+
RunState,
|
|
63
|
+
RunSummary,
|
|
64
|
+
} from "./summary.ts";
|
|
65
|
+
export { applyEvent, initialSummary } from "./summary.ts";
|
|
66
|
+
export type {
|
|
67
|
+
AgentStats,
|
|
68
|
+
AgentTransport,
|
|
69
|
+
AskMarker,
|
|
70
|
+
AskMarkerContext,
|
|
71
|
+
AskPlayback,
|
|
72
|
+
Frame,
|
|
73
|
+
TokenBreakdown,
|
|
74
|
+
TransportFactory,
|
|
75
|
+
TransportStartup,
|
|
76
|
+
TransportStartupObserver,
|
|
77
|
+
WorktreeResolution,
|
|
78
|
+
} from "./transport.ts";
|
|
79
|
+
export type {
|
|
80
|
+
AskOptions,
|
|
81
|
+
Handle,
|
|
82
|
+
SpawnOptions,
|
|
83
|
+
SpawnOverrides,
|
|
84
|
+
StructuredAskOptions,
|
|
85
|
+
ThinkingLevel,
|
|
86
|
+
} from "./types.ts";
|
|
87
|
+
export {
|
|
88
|
+
AGENT_NODE_TABLE_MAX,
|
|
89
|
+
ASK_OUTPUT_FLUSH_INTERVAL_MS,
|
|
90
|
+
ASK_OUTPUT_MAX_BYTES,
|
|
91
|
+
ASK_OUTPUT_TRUNCATION_MARKER,
|
|
92
|
+
NODE_GIST_MAX_CHARS,
|
|
93
|
+
PROMPT_GIST_MAX_CHARS,
|
|
94
|
+
TOOL_ARGS_GIST_MAX_CHARS,
|
|
95
|
+
} from "./wire-constants.ts";
|
|
96
|
+
export { worktreeTransport } from "./worktree-transport.ts";
|