@yaag/runtime 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaag/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export type {
72
72
  RunSummary,
73
73
  } from "./summary.ts";
74
74
  export { applyEvent, initialSummary } from "./summary.ts";
75
+ export { readSystemPromptSidecar } from "./system-prompt-recorder.ts";
75
76
  export type {
76
77
  AgentStats,
77
78
  AgentTransport,
@@ -5,9 +5,10 @@ import { piCommand, readModel, readSessionFile, readStats } from "./pi-state.ts"
5
5
  import { reap } from "./reap.ts";
6
6
  import {
7
7
  createToolProbe,
8
- requestedToolNames,
9
8
  TOOL_PROBE_EXTENSION_PATH,
9
+ type ToolContract,
10
10
  type ToolProbe,
11
+ toolContract,
11
12
  verifyEffectiveTools,
12
13
  } from "./tool-probe.ts";
13
14
  import type {
@@ -44,17 +45,18 @@ class LiveTransport implements AgentTransport {
44
45
  readonly #process: Bun.Subprocess<"pipe", "pipe", "ignore">;
45
46
  readonly #queue = new FrameQueue();
46
47
  readonly #pending = new Map<string, (frame: Frame) => void>();
47
- readonly #toolProbe: ToolProbe | null;
48
- readonly #promisedTools: readonly string[] | null;
48
+ /** Both halves of tool verification, or null when the spawn promised nothing. */
49
+ readonly #verification: { readonly contract: ToolContract; readonly probe: ToolProbe } | null;
50
+
49
51
  #nextId = 0;
50
52
  #closing: Promise<AgentStats> | null = null;
51
53
 
52
54
  constructor(options: OpenOptions) {
53
55
  this.#name = options.name;
54
- this.#promisedTools = requestedToolNames(options.tools, options.disallowedTools);
55
- this.#toolProbe = createToolProbe(options.tools, options.disallowedTools);
56
+ const contract = toolContract(options);
57
+ this.#verification = contract === null ? null : { contract, probe: createToolProbe() };
56
58
  this.#process = Bun.spawn({
57
- cmd: piCommand(options, this.#toolProbe ? TOOL_PROBE_EXTENSION_PATH : undefined),
59
+ cmd: piCommand(options, this.#verification ? TOOL_PROBE_EXTENSION_PATH : undefined),
58
60
  cwd: options.cwd,
59
61
  stdin: "pipe",
60
62
  stdout: "pipe",
@@ -72,8 +74,9 @@ class LiveTransport implements AgentTransport {
72
74
  const state = await this.#command({ type: "get_state" }, READY_TIMEOUT_MS);
73
75
  const model = readModel(state);
74
76
  if (!model) throw new Error(`agent "${this.#name}" reported no model`);
75
- if (this.#toolProbe && this.#promisedTools) {
76
- verifyEffectiveTools(this.#promisedTools, await this.#toolProbe.wait());
77
+ if (this.#verification !== null) {
78
+ const { contract, probe } = this.#verification;
79
+ verifyEffectiveTools(contract, await probe.wait());
77
80
  }
78
81
  this.model = model;
79
82
  const sessionFile = readSessionFile(state);
@@ -133,7 +136,7 @@ class LiveTransport implements AgentTransport {
133
136
  },
134
137
  });
135
138
  this.#queue.end();
136
- this.#toolProbe?.fail(
139
+ this.#verification?.probe.fail(
137
140
  new Error(`agent "${this.#name}" exited before tool verification completed`),
138
141
  );
139
142
  return stats;
@@ -179,7 +182,7 @@ class LiveTransport implements AgentTransport {
179
182
 
180
183
  async #read(): Promise<void> {
181
184
  for await (const frame of decodeFrames(readChunks(this.#process.stdout))) {
182
- if (this.#toolProbe?.accept(frame)) continue;
185
+ if (this.#verification?.probe.accept(frame)) continue;
183
186
  const id = typeof frame.id === "string" ? frame.id : "";
184
187
  const waiting = frame.type === "response" ? this.#pending.get(id) : undefined;
185
188
  if (waiting) {
@@ -190,7 +193,7 @@ class LiveTransport implements AgentTransport {
190
193
  this.#queue.push(frame);
191
194
  }
192
195
  this.#queue.end();
193
- this.#toolProbe?.fail(
196
+ this.#verification?.probe.fail(
194
197
  new Error(`agent "${this.#name}" exited before tool verification completed`),
195
198
  );
196
199
  }
package/src/pi-state.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { REPORT_RESULT_EXTENSION_PATH, REPORT_RESULT_TOOL_NAME } from "./report-result.ts";
2
+ import { SYSTEM_PROMPT_RECORDER_EXTENSION_PATH } from "./system-prompt-recorder.ts";
2
3
  import type { AgentStats, Frame, OpenOptions, TokenBreakdown } from "./transport.ts";
3
4
 
4
5
  /** What the Agent reported about its own work, read from a `get_state` probe. */
@@ -33,7 +34,7 @@ function appendCapabilities(
33
34
  toolProbeExtensionPath: string | undefined,
34
35
  ): void {
35
36
  const hermetic = options.inherit !== true;
36
- appendTools(cmd, options, hermetic);
37
+ appendTools(cmd, options);
37
38
  if (options.resolvedSkillPaths === undefined) {
38
39
  if (hermetic) cmd.push("--no-skills");
39
40
  } else {
@@ -48,13 +49,12 @@ function appendCapabilities(
48
49
  // output schema long after the process started and pi loads extensions only
49
50
  // at startup (ADR-0032).
50
51
  cmd.push("-e", REPORT_RESULT_EXTENSION_PATH);
51
- if (
52
- options.tools !== undefined &&
53
- options.tools.length > 0 &&
54
- toolProbeExtensionPath !== undefined
55
- ) {
56
- cmd.push("-e", toolProbeExtensionPath);
57
- }
52
+ // The caller decides whether the probe runs: it loads the extension only when
53
+ // the spawn flags promised something verifiable (see toolContract).
54
+ if (toolProbeExtensionPath !== undefined) cmd.push("-e", toolProbeExtensionPath);
55
+ // The system-prompt recorder loads last of all: `before_agent_start` handlers
56
+ // chain in load order, so only the last one sees every other append.
57
+ cmd.push("-e", SYSTEM_PROMPT_RECORDER_EXTENSION_PATH);
58
58
  if (options.disallowedTools !== undefined) {
59
59
  // The private tool is never a program's to deny: excluding it would fail
60
60
  // every schema-bearing Ask with "agent did not call report_result". A
@@ -66,8 +66,18 @@ function appendCapabilities(
66
66
  }
67
67
  }
68
68
 
69
+ /** The spawn options that decide pi's `--tools` allowlist. */
70
+ export interface ToolSelection {
71
+ readonly tools?: readonly string[] | undefined;
72
+ readonly inherit?: boolean | undefined;
73
+ }
74
+
69
75
  /**
70
- * Expresses the tool baseline as an allowlist that keeps `report_result`.
76
+ * The names for pi's `--tools` flag, or null when yaag emits no allowlist and
77
+ * pi composes the toolset itself.
78
+ *
79
+ * This is the single source of the allowlist rule: `piCommand` writes the flag
80
+ * from it, and `toolContract` in tool-probe.ts states its promise from it.
71
81
  *
72
82
  * `--no-tools` strips extension tools as well, and refuses a tool registered
73
83
  * afterwards, so "no tools" becomes an allowlist of exactly yaag's own tool
@@ -75,13 +85,17 @@ function appendCapabilities(
75
85
  * schema-bearing Ask activates it, so the Agent's effective capability is
76
86
  * unchanged.
77
87
  */
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;
88
+ export function toolAllowlist(options: ToolSelection): readonly string[] | null {
89
+ const { tools } = options;
90
+ if (tools === undefined) {
91
+ return options.inherit === true ? null : [REPORT_RESULT_TOOL_NAME];
82
92
  }
83
- const allowed = [...new Set([...options.tools, REPORT_RESULT_TOOL_NAME])];
84
- cmd.push("--tools", allowed.join(","));
93
+ return [...new Set([...tools, REPORT_RESULT_TOOL_NAME])];
94
+ }
95
+
96
+ function appendTools(cmd: string[], options: OpenOptions): void {
97
+ const allowed = toolAllowlist(options);
98
+ if (allowed !== null) cmd.push("--tools", allowed.join(","));
85
99
  }
86
100
 
87
101
  /**
@@ -0,0 +1,95 @@
1
+ /**
2
+ * yaag's private system-prompt recorder, loaded into every Agent at spawn.
3
+ *
4
+ * pi assembles the system prompt per turn and never persists it in the session
5
+ * file, so a Peek has nothing to show (observation #1204). This extension
6
+ * writes the assembled prompt to a sidecar file beside the session file, and pi
7
+ * loads it last, so the prompt it records holds every other extension's
8
+ * appends. It records rather than reports, so it is no probe: it sends no frame
9
+ * and the Orchestrator never waits for it (ADR-0035).
10
+ *
11
+ * It is strictly best-effort and silent: an Agent without a session file, or a
12
+ * write that fails, records nothing. It never changes the prompt, and it emits
13
+ * no frame, so no Ask can settle differently because of it.
14
+ *
15
+ * Types are declared locally rather than imported from pi: this file is passed
16
+ * to `pi -e` as a path and must load without yaag's dependency graph. pi loads
17
+ * it through jiti, in Node, so it uses `node:fs/promises` and no Bun-only API.
18
+ */
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { rename, unlink, writeFile } from "node:fs/promises";
22
+
23
+ /** The pi event that carries the assembled prompt of one turn. */
24
+ interface BeforeAgentStart {
25
+ readonly systemPrompt?: unknown;
26
+ }
27
+
28
+ /** The pi context slice this extension reads. */
29
+ interface RecorderContext {
30
+ readonly sessionManager: { getSessionFile(): string | undefined };
31
+ }
32
+
33
+ /** The pi surface this extension registers on. */
34
+ interface RecorderAPI {
35
+ on(
36
+ event: "before_agent_start",
37
+ handler: (event: BeforeAgentStart, context: RecorderContext) => Promise<undefined>,
38
+ ): void;
39
+ }
40
+
41
+ /** Extension of the sidecar that holds one Agent's latest system prompt. */
42
+ export const SYSTEM_PROMPT_SIDECAR_SUFFIX = ".system-prompt.md";
43
+
44
+ /**
45
+ * The sidecar path for one pi session file.
46
+ *
47
+ * The session file's `.jsonl` extension is dropped, so `<id>.jsonl` and its
48
+ * sidecar `<id>.system-prompt.md` sit next to each other in the session dir.
49
+ */
50
+ export function systemPromptSidecarPath(sessionFile: string): string {
51
+ const stem = sessionFile.endsWith(".jsonl")
52
+ ? sessionFile.slice(0, -".jsonl".length)
53
+ : sessionFile;
54
+ return `${stem}${SYSTEM_PROMPT_SIDECAR_SUFFIX}`;
55
+ }
56
+
57
+ export default function (pi: RecorderAPI): void {
58
+ pi.on("before_agent_start", async (event, ctx) => {
59
+ await record(event, ctx);
60
+ // The prompt is returned unchanged: an undefined result is no edit.
61
+ return undefined;
62
+ });
63
+ }
64
+
65
+ async function record(event: BeforeAgentStart, ctx: RecorderContext): Promise<void> {
66
+ const prompt = event.systemPrompt;
67
+ if (typeof prompt !== "string") return;
68
+ const sessionFile = readSessionFile(ctx);
69
+ // An ephemeral session has nowhere to put a sidecar, and a Peek of it has no
70
+ // session file to read either.
71
+ if (sessionFile === null) return;
72
+ const target = systemPromptSidecarPath(sessionFile);
73
+ // A reader must never see half a prompt: the write lands under a temporary
74
+ // name and the rename publishes it in one step. The name is unique for each
75
+ // write, so a file left by a killed process is never mistaken for a live one.
76
+ const temporary = `${target}.${randomUUID()}.tmp`;
77
+ try {
78
+ await writeFile(temporary, prompt, "utf8");
79
+ await rename(temporary, target);
80
+ } catch {
81
+ // Best-effort by design: recording a prompt must never fail a turn. A pi
82
+ // killed between the write and the rename still leaves its temporary file
83
+ // behind; that window is accepted, and no reader ever opens the file.
84
+ await unlink(temporary).catch(() => {});
85
+ }
86
+ }
87
+
88
+ function readSessionFile(ctx: RecorderContext): string | null {
89
+ try {
90
+ const sessionFile = ctx.sessionManager.getSessionFile();
91
+ return sessionFile === undefined || sessionFile === "" ? null : sessionFile;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where the private system-prompt recorder lives, and how a Peek reads what it
3
+ * wrote (ADR-0035).
4
+ *
5
+ * `piCommand` loads the extension from here, and every Peek host reads the
6
+ * sidecar through `readSystemPromptSidecar` here, so the writer and the readers
7
+ * derive the same path from one place.
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { systemPromptSidecarPath } from "./system-prompt-recorder-extension.ts";
13
+
14
+ export { systemPromptSidecarPath };
15
+
16
+ /** Filesystem sibling path of the recorder; decodes percent-escapes (e.g. spaces). */
17
+ export function systemPromptRecorderExtensionPath(moduleUrl: string): string {
18
+ return fileURLToPath(new URL("./system-prompt-recorder-extension.ts", moduleUrl));
19
+ }
20
+
21
+ /** Loadable source path for the private extension that records the system prompt. */
22
+ export const SYSTEM_PROMPT_RECORDER_EXTENSION_PATH = systemPromptRecorderExtensionPath(
23
+ import.meta.url,
24
+ );
25
+
26
+ /**
27
+ * Reads the system-prompt sidecar of one Agent, or null when there is none.
28
+ *
29
+ * Null covers every case a Peek cannot show a real prompt for: an Agent that
30
+ * reported no session file, a session whose first turn has not started, and a
31
+ * session written before yaag recorded a sidecar. A Peek then falls back to the
32
+ * session file. An Agent of a replayed Run has the recording's session path, so
33
+ * a Peek on the recording machine reads the original Agent's sidecar, exactly
34
+ * as it reads the original Agent's transcript.
35
+ */
36
+ export async function readSystemPromptSidecar(
37
+ sessionFile: string | null | undefined,
38
+ ): Promise<string | null> {
39
+ if (sessionFile === undefined || sessionFile === null) return null;
40
+ try {
41
+ return await readFile(systemPromptSidecarPath(sessionFile), "utf8");
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
package/src/tool-probe.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
 
3
+ import { type ToolSelection, toolAllowlist } from "./pi-state.ts";
4
+ import { REPORT_RESULT_TOOL_NAME } from "./report-result.ts";
3
5
  import type { Frame } from "./transport.ts";
4
6
 
5
7
  /** The private status key used only by yaag's internal pi tool probe extension. */
@@ -24,34 +26,64 @@ export interface ToolProbe {
24
26
  fail(error: Error): void;
25
27
  }
26
28
 
29
+ /** What the spawn flags promised about the Agent's effective toolset. */
30
+ export interface ToolContract {
31
+ /** Names that must be active at startup. Never holds `report_result`. */
32
+ readonly required: readonly string[];
33
+ /** The only names that may be active; null when the effective set is unpredictable. */
34
+ readonly allowed: readonly string[] | null;
35
+ /** Names that must not be active. */
36
+ readonly denied: readonly string[];
37
+ }
38
+
39
+ /** The spawn options that decide what the probe can verify. */
40
+ export interface ToolContractOptions extends ToolSelection {
41
+ readonly disallowedTools?: readonly string[] | undefined;
42
+ }
43
+
44
+ function dedupe(names: Iterable<string>): string[] {
45
+ return [...new Set(names)];
46
+ }
47
+
27
48
  /**
28
- * Returns promised tools that survive pi's final denylist composition.
49
+ * Returns what the spawn flags promised, or null when they promised nothing.
50
+ *
51
+ * Two promises are verifiable. A spawn that pi gets a `--tools` allowlist for
52
+ * promises that the allowlist holds every active name. A spawn that only denies
53
+ * tools promises the absence of every denied name, and nothing about the rest.
54
+ * An inheriting spawn without `tools` and without `disallowedTools` promises
55
+ * nothing, so it needs no probe.
29
56
  *
30
- * Null means no tool allowlist promised anything and therefore needs no probe.
57
+ * `report_result` is allowed but never required: report-result-extension.ts
58
+ * registers the tool when a schema-bearing Ask activates it, long after startup
59
+ * (ADR-0032), so at startup the probe reports it as inactive.
31
60
  */
32
- export function requestedToolNames(
33
- tools: readonly string[] | undefined,
34
- disallowedTools: readonly string[] | undefined = undefined,
35
- ): readonly string[] | null {
36
- if (tools === undefined || tools.length === 0) return null;
37
- const denied = new Set(disallowedTools);
38
- const names: string[] = [];
39
- const seen = new Set<string>();
40
- for (const tool of tools) {
41
- if (denied.has(tool) || seen.has(tool)) continue;
42
- seen.add(tool);
43
- names.push(tool);
61
+ export function toolContract(options: ToolContractOptions): ToolContract | null {
62
+ // pi-state.ts strips this name from `--exclude-tools`, so it is never denied.
63
+ const denied = dedupe(options.disallowedTools ?? []).filter(
64
+ (name) => name !== REPORT_RESULT_TOOL_NAME,
65
+ );
66
+ const allowlisted = toolAllowlist(options);
67
+ if (allowlisted === null) {
68
+ if (denied.length === 0) return null;
69
+ return { required: [], allowed: null, denied };
44
70
  }
45
- return names;
71
+ const deniedSet = new Set(denied);
72
+ const allowed = allowlisted.filter((name) => !deniedSet.has(name));
73
+ return {
74
+ required: allowed.filter((name) => name !== REPORT_RESULT_TOOL_NAME),
75
+ allowed,
76
+ denied,
77
+ };
46
78
  }
47
79
 
48
- /** Creates a startup collector only when a non-empty allowlist promised tool selection. */
49
- export function createToolProbe(
50
- tools: readonly string[] | undefined,
51
- _disallowedTools: readonly string[] | undefined = undefined,
52
- timeoutMs = TOOL_PROBE_TIMEOUT_MS,
53
- ): ToolProbe | null {
54
- if (tools === undefined || tools.length === 0) return null;
80
+ /**
81
+ * Creates a startup collector for the probe's report.
82
+ *
83
+ * The caller creates one only when `toolContract` returned a contract to verify
84
+ * it against, and loads the probe extension in the same decision.
85
+ */
86
+ export function createToolProbe(timeoutMs = TOOL_PROBE_TIMEOUT_MS): ToolProbe {
55
87
  let resolveResult: (names: readonly string[]) => void = () => {};
56
88
  let rejectResult: (error: Error) => void = () => {};
57
89
  let settled = false;
@@ -126,16 +158,34 @@ export function parseToolProbeFrame(frame: Frame): readonly string[] {
126
158
  return names;
127
159
  }
128
160
 
129
- /** Throws a diagnostic naming every missing requested tool. */
130
- export function verifyEffectiveTools(
131
- promised: readonly string[],
132
- effective: readonly string[],
133
- ): void {
161
+ function quoteNames(names: readonly string[]): string {
162
+ return names.map((name) => JSON.stringify(name)).join(", ");
163
+ }
164
+
165
+ /**
166
+ * Throws a diagnostic when the effective toolset breaks the promise.
167
+ *
168
+ * A missing required tool, an extra tool outside the allowlist, and a denied
169
+ * tool that is still active all fail the spawn the same way.
170
+ */
171
+ export function verifyEffectiveTools(contract: ToolContract, effective: readonly string[]): void {
134
172
  const active = new Set(effective);
135
- const missing = promised.filter((name) => !active.has(name));
136
- if (missing.length === 0) return;
137
- const listed = missing.map((name) => JSON.stringify(name)).join(", ");
138
- throw new Error(
139
- `agent is missing requested tool(s): ${listed}; declare the extension(s) providing these tools via spawn.extensions`,
140
- );
173
+ const missing = contract.required.filter((name) => !active.has(name));
174
+ if (missing.length > 0) {
175
+ throw new Error(
176
+ `agent is missing requested tool(s): ${quoteNames(missing)}; declare the extension(s) providing these tools via spawn.extensions`,
177
+ );
178
+ }
179
+ const stillActive = contract.denied.filter((name) => active.has(name));
180
+ if (stillActive.length > 0) {
181
+ throw new Error(`agent has denied tool(s) still active: ${quoteNames(stillActive)}`);
182
+ }
183
+ if (contract.allowed === null) return;
184
+ const allowed = new Set(contract.allowed);
185
+ const extra = dedupe(effective).filter((name) => !allowed.has(name));
186
+ if (extra.length > 0) {
187
+ throw new Error(
188
+ `agent has unexpected active tool(s): ${quoteNames(extra)}; the spawn allowlist promised only ${quoteNames(contract.allowed)}`,
189
+ );
190
+ }
141
191
  }