@introspection-ai/operator 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -9,19 +9,31 @@ The Operator is the agent that builds, deploys and improves other agents (Recipe
9
9
  | `evals/` | The evals that measure each loop (to come) |
10
10
  | `docs/` | [`DESIGN.md`](docs/DESIGN.md): what the Operator is being rebuilt to, and the decisions still open |
11
11
 
12
- ## Run build locally
12
+ ## Run build
13
+
14
+ You need Node 24, [uv](https://docs.astral.sh/uv/) and credentials for the model in `agents/build.yaml` (sign in once with `/login` in Pi).
13
15
 
14
16
  ```sh
15
- pnpm build # a new agent, in ~/.introspection/agents/new-<n>
16
- pnpm build ~/.introspection/agents/new-1 # resume that build
17
- pnpm build ~/work/my-agent --model <model> # another folder; flags after it go to Pi
17
+ npm i -g @introspection-ai/cli && introspection setup # once: Pi and the Recipes release it needs
18
+ npx @introspection-ai/operator # a new agent, in ~/.introspection/agents/new-<n>
19
+ npx @introspection-ai/operator ~/work/my-agent # start or resume a build in that folder
20
+ npx @introspection-ai/operator ~/work/my-agent --model <model> # flags after the folder go to Pi
18
21
  ```
19
22
 
20
- `scripts/build.sh` starts the build agent (`pi --recipe <this repo> --agent build --no-context-files`) in a folder of its own, because build treats the folder it starts in as the owner's workspace: it creates the agent's repository there and resumes from what it finds there. It turns off Pi's loading of AGENTS.md and CLAUDE.md from that folder and the folders above it, which are written for coding agents and can describe other agents build must not copy. It refuses a folder inside this repository or nested in another git repository, installs this repository's dependencies with pnpm once, and checks that Pi's `@introspection-ai/recipes` satisfies the `peerDependencies` range, printing the `pi install` command to run when it doesn't. Set `INTROSPECTION_AGENTS_DIR` to put new builds somewhere other than `~/.introspection/agents`. You need Pi and the `introspection` CLI (`npm i -g @introspection-ai/cli`), and credentials for the model in `agents/build.yaml`.
23
+ `npx @introspection-ai/operator` runs the package's launcher, `bin/introspection-operator.js`. It starts the build agent (`pi --recipe <the package> --agent build --no-context-files -e bin/local-workspace.mjs`) in a folder of its own, because build treats the folder it starts in as the owner's workspace: it creates the agent's repository there and resumes from what it finds there.
24
+ - **No AGENTS.md or CLAUDE.md:** it turns off Pi's loading of those files from that folder and the folders above it. They're written for coding agents and can describe other agents, which build must not copy.
25
+ - **Folder safety:** it refuses a folder inside the package or nested in another git repository; the agent's own repository is allowed, to resume.
26
+ - **Python:** it builds the Recipe's Python environment (`package.json#pi.runtime.python`: paper-docx, pypdf, pypdfium2 and pillow, locked in `python/uv.lock`) with `uv sync --frozen` into `~/.introspection/operator-python/<version>-<lockfile hash>` (outside the package, which a global install may not let it write to; rebuilt only when the pins change), checks the declared imports, and starts Pi with it as `VIRTUAL_ENV`, so the `documents` tool can read and mark up Word and PDF files. Pi doesn't build `pi.runtime.python` itself.
27
+ - **Shared documents:** it creates `files/` and `outputs/` in the build folder, beside the agent's repository, and loads `bin/local-workspace.mjs`, which tells the agent where they are, as the cloud does with `/workspace/files` and `/workspace/outputs`. Put the documents you'll share in `files/`: the documents tool opens files only inside the build folder. Given a path elsewhere, the agent copies the file into `files/` first.
28
+ - **Versions:** it checks that Pi's `@introspection-ai/recipes` satisfies the `peerDependencies` range, and says to run `introspection upgrade` when it doesn't.
29
+
30
+ Set `INTROSPECTION_AGENTS_DIR` to put new builds somewhere other than `~/.introspection/agents`.
31
+
32
+ In a checkout of this repository, `pnpm build` runs the same launcher, and installs the dependencies with pnpm once.
21
33
 
22
34
  ## Package
23
35
 
24
- `@introspection-ai/operator` is the npm package published from this repository's root. It contains the Recipe a person runs (`SYSTEM.md`, `agents/` and `skills/`), so `pi install npm:@introspection-ai/operator` then `pi --recipe @introspection-ai/operator --agent build` starts a build, and `npx skills sync` finds the skills in it. `docs/`, `scripts/` and the evals are never published.
36
+ `@introspection-ai/operator` is the npm package published from this repository's root. It contains the Recipe a person runs (`SYSTEM.md`, `agents/`, `skills/`, and `python/pyproject.toml` and `uv.lock` for its documents tool) and its launcher (`bin/`), so `npx @introspection-ai/operator` starts a build, and `npx skills sync` finds the skills in it. `docs/`, `scripts/` and the evals are never published.
25
37
 
26
38
  Skills are grouped by the stage of an agent's life (see [`docs/DESIGN.md`](docs/DESIGN.md)), and the group is the prefix of each skill's name, because `npx skills sync` finds skills only at `skills/<name>/SKILL.md` and not in nested folders:
27
39
 
@@ -33,8 +45,8 @@ Skills are grouped by the stage of an agent's life (see [`docs/DESIGN.md`](docs/
33
45
  | `optimize` | `optimize-quality`, `optimize-cost`, `optimize-latency` | Raise quality, then cut cost, then latency, each holding the gains of the one before | Designed; replaces today's `optimize` |
34
46
  | `harden` | `harden-scan`, `harden-spar`, `harden-stress` | Last, and optional apart from the scan: check the Recipe against the OWASP Agentic Skills Top 10, spar with it as a red team, and test it under load; failures become permanent cases | Designed; new, with no workflow behind it today |
35
47
 
36
- Releases are cut by release-please (`.github/workflows/release.yml`): Conventional Commit titles on `main` update a release pull request, and merging it tags `operator-vX.Y.Z` and publishes to npm through trusted publishing, starting at 0.1.0. CI runs `node scripts/check-skills.mjs` (frontmatter, folder names, and that exactly the Recipe is packed: `skills/`, `agents/` and `SYSTEM.md`) and `npm pack --dry-run`; publishing also requires at least one skill.
48
+ Releases are cut by release-please (`.github/workflows/release.yml`): Conventional Commit titles on `main` update a release pull request, and merging it tags `operator-vX.Y.Z` and publishes to npm through trusted publishing, starting at 0.1.0. CI runs `node scripts/check-skills.mjs` (frontmatter, folder names, and that exactly the Recipe and its launcher are packed: `skills/`, `agents/`, `SYSTEM.md`, `python/pyproject.toml`, `python/uv.lock` and `bin/introspection-operator.js`) and `npm pack --dry-run`; publishing also requires at least one skill.
37
49
 
38
50
  ## License
39
51
 
40
- What `@introspection-ai/operator` publishes (`SYSTEM.md`, `agents/` and `skills/`) is licensed under the Apache License 2.0; see [`skills/LICENSE`](skills/LICENSE). Everything else in this repository is proprietary to Introspection and not licensed for use outside it.
52
+ What `@introspection-ai/operator` publishes (`SYSTEM.md`, `agents/`, `skills/` and `bin/`) is licensed under the Apache License 2.0; see [`skills/LICENSE`](skills/LICENSE). Everything else in this repository is proprietary to Introspection and not licensed for use outside it.
package/SYSTEM.md CHANGED
@@ -4,7 +4,7 @@ Work in loops, and keep the work in the agent's repository, not in the conversat
4
4
 
5
5
  Owners are usually business owners, not engineers. Speak plainly, ask only what changes what the agent does, and show a concrete example when a question would be abstract. An owner's approval is an explicit message from them, never inferred.
6
6
 
7
- Documents the owner gives you are of two kinds. The owner's own standing material (their playbook, guidelines, standard terms, templates) is meant to be built into the agent: read it when they give it to you and commit it with the Recipe. Material that arrives with a request (a customer's contract, an inbox) is a case input: read it, refer to it by content hash, and never commit it.
7
+ When you need a document from the owner, ask them to share it: as an upload, unless the workspace says how this owner shares files. Documents the owner gives you are of two kinds. The owner's own standing material (their playbook, guidelines, standard terms, templates) is meant to be built into the agent: read it when they give it to you and commit it with the Recipe. Material that arrives with a request (a customer's contract, an inbox) is a case input: read it where it was shared, refer to it by content hash, and never commit it or copy it into the agent's repository.
8
8
 
9
9
  To read or show a Word or PDF document, use the `documents` tool: it reads text, tables and comments, renders scanned pages as images, and for Word makes tracked changes and comments, so you can show the owner a real example markup on their own contract. Without it, note that Pi's `read` tool handles text and images but returns Word and PDF files as raw bytes. Extract their text instead, preferring what's installed: `pdftotext` for PDF (the platform image always has it) and an installed `python-docx` for Word. Only when developing locally without them, use `uv run --with pypdf python` or `uv run --with python-docx python`; on the platform nothing is downloaded while you run. Never call a document unreadable or image-based without trying text extraction first; if it truly has no text, say so plainly and ask for another copy.
10
10
 
package/agents/build.yaml CHANGED
@@ -3,7 +3,7 @@ description: Builds a new agent with its owner, from first idea to a pull reques
3
3
  ai:
4
4
  model: openai/gpt-6-luna
5
5
  thinking_level: medium
6
- tools: [read, bash, edit, write, ask_user_question, documents]
6
+ tools: [read, bash, edit, write, ask_user_question, documents, run_case, case_status, stop_case]
7
7
  skills: [build-brainstorm, build-draft, build-review]
8
8
  system_instructions:
9
9
  mode: append
@@ -30,7 +30,7 @@ system_instructions:
30
30
  question per call: a short header, the question, and two or three option
31
31
  labels without numbers (the tool numbers them and always lets the owner
32
32
  type their own answer). Don't list the options in the question text. When
33
- the answer must be free text, such as a file path, ask it in your reply
33
+ the answer must be free text, or files the owner shares, ask in your reply
34
34
  instead. Write each question so it works read aloud: under about 75 words,
35
35
  one decision, with enough context that a bare option number still makes
36
36
  sense. Without a question tool, write up to three numbered options in the
@@ -39,8 +39,10 @@ system_instructions:
39
39
  the step; name the step only in its first message. Never re-ask something
40
40
  answered.
41
41
 
42
- When the owner gives you a path to a document, read it straight away;
43
- don't ask permission or ask for a summary instead.
42
+ Read the documents the owner shares straight away, from the inputs
43
+ folder the workspace names; don't ask permission or ask for a summary
44
+ instead. Write what you make for the owner, such as an example result on
45
+ their sample, to the workspace's outputs folder.
44
46
 
45
47
  At the start of every session, find out where the work is before asking
46
48
  anything, looking only in the current folder and the folders inside it.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Start the Operator's build agent in a folder of its own.
3
+ //
4
+ // npx @introspection-ai/operator new build in ~/.introspection/agents/new-<n>
5
+ // npx @introspection-ai/operator <folder> start, or resume, a build in <folder>
6
+ // npx @introspection-ai/operator [<folder>] <pi flags> pass flags such as --model on to Pi
7
+ //
8
+ // In a checkout of this repository, `pnpm build` runs this same script.
9
+ //
10
+ // Build treats the folder it starts in as the owner's workspace: it creates the agent's repository
11
+ // there and resumes from what it finds, so it must not start inside this package or another repository.
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
21
+ const agentsDir = process.env.INTROSPECTION_AGENTS_DIR || join(homedir(), ".introspection", "agents");
22
+ const piDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
23
+
24
+ function fail(message) {
25
+ console.error(`introspection-operator: ${message}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ let args = process.argv.slice(2);
30
+ if (args[0] === "--help" || args[0] === "-h") {
31
+ console.log(readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 8).map((l) => l.replace(/^\/\/ ?/, "")).join("\n"));
32
+ process.exit(0);
33
+ }
34
+ if (args[0] === "--") args = args.slice(1); // pnpm passes a separating `--` through
35
+
36
+ // A package manager puts this package's node_modules/.bin first on PATH, and the Recipes package installs
37
+ // its own Pi there, one without the Recipe extension. Use the Pi the owner installed.
38
+ const ownBin = join(root, "node_modules", ".bin");
39
+ const path = (process.env.PATH || "").split(delimiter).filter((dir) => resolve(dir) !== ownBin).join(delimiter);
40
+ const env = { ...process.env, PATH: path };
41
+ const has = (cmd) => spawnSync(cmd, ["--version"], { env, stdio: "ignore", shell: process.platform === "win32" }).status === 0;
42
+ if (!has("pi")) fail("Pi is not installed. Run: npm i -g @introspection-ai/cli && introspection setup");
43
+
44
+ // The Recipe's own dependencies (the documents tool) load from this package by Node's resolution, which
45
+ // also finds them where npx and npm hoist them, beside the package. A fresh checkout installs them once.
46
+ // Search node_modules folders upward from the package, as Node does; resolving `<name>/package.json`
47
+ // would fail for a package whose exports don't list it.
48
+ const resolvable = (name) => {
49
+ for (let dir = root; ; dir = dirname(dir)) {
50
+ if (existsSync(join(dir, "node_modules", name, "package.json"))) return true;
51
+ if (dirname(dir) === dir) return false;
52
+ }
53
+ };
54
+ if (!Object.keys(pkg.dependencies ?? {}).every(resolvable)) {
55
+ if (!existsSync(join(root, "pnpm-lock.yaml"))) fail(`can't find the Operator's dependencies from ${root}; install it with npm or npx`);
56
+ console.error("introspection-operator: installing the Operator's dependencies (once)");
57
+ execFileSync("pnpm", ["install", "--frozen-lockfile", "--reporter=silent"], { cwd: root, env, stdio: "inherit", shell: process.platform === "win32" });
58
+ }
59
+
60
+ // Pi loads the Recipe through its own copy of @introspection-ai/recipes, which must satisfy the peer range.
61
+ const range = pkg.peerDependencies?.["@introspection-ai/recipes"] ?? "";
62
+ const min = /^>=\s*(\d+\.\d+\.\d+)/.exec(range)?.[1];
63
+ const piRecipes = join(piDir, "npm", "node_modules", "@introspection-ai", "recipes", "package.json");
64
+ if (min) {
65
+ if (!existsSync(piRecipes)) fail("Pi has no @introspection-ai/recipes package. Run: introspection setup");
66
+ const have = JSON.parse(readFileSync(piRecipes, "utf8")).version;
67
+ const num = (v) => v.split(/[.-]/).slice(0, 3).map(Number);
68
+ const [a, b] = [num(have), num(min)];
69
+ if ((a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) < 0) {
70
+ fail(`Pi uses @introspection-ai/recipes ${have}; the Operator needs ${range}. Run: introspection upgrade`);
71
+ }
72
+ }
73
+
74
+ // The Recipe's Python (package.json#pi.runtime.python): the documents tool runs its Word and PDF helpers with
75
+ // VIRTUAL_ENV's python. Pi doesn't build that environment, so build it here from the committed lockfile, never
76
+ // into system Python, and refuse to start without it. It goes under ~/.introspection, not into the package:
77
+ // a global install can sit in a folder that needs sudo. Keyed by version and lockfile, so it's rebuilt only
78
+ // when the pins change and versions sit side by side.
79
+ const python = pkg.pi?.runtime?.python;
80
+ if (python) {
81
+ if (!has("uv")) fail("uv is not installed; the documents tool needs it. Install it: https://docs.astral.sh/uv/getting-started/installation/");
82
+ const project = join(root, python.project);
83
+ const lock = readFileSync(join(root, python.lockfile));
84
+ const key = createHash("sha256").update(lock).digest("hex").slice(0, 12);
85
+ const venv = join(homedir(), ".introspection", "operator-python", `${pkg.version}-${key}`);
86
+ const uvEnv = { ...env, UV_PROJECT_ENVIRONMENT: venv };
87
+ const sync = spawnSync("uv", ["sync", "--frozen", "--no-install-project", "--quiet", "--project", project], { env: uvEnv, stdio: "inherit", shell: process.platform === "win32" });
88
+ if (sync.status !== 0) fail(`couldn't build the Recipe's Python environment from ${join(root, python.lockfile)}`);
89
+ const bin = join(venv, process.platform === "win32" ? "Scripts" : "bin");
90
+ env.VIRTUAL_ENV = venv;
91
+ env.PATH = [bin, env.PATH].join(delimiter);
92
+ delete env.PYTHONHOME;
93
+ const imports = python.imports ?? [];
94
+ if (imports.length > 0) {
95
+ const check = spawnSync(join(bin, "python"), ["-c", imports.map((name) => `import ${name}`).join("; ")], { env, stdio: "inherit" });
96
+ if (check.status !== 0) fail(`the Recipe's Python environment is missing one of: ${imports.join(", ")}`);
97
+ }
98
+ }
99
+
100
+ // The folder: the first argument when it isn't a flag, else the next new-<n>. A relative folder resolves
101
+ // from where the command was typed (pnpm runs scripts from the package root, so INIT_CWD first).
102
+ const typedFrom = process.env.INIT_CWD || process.cwd();
103
+ let target;
104
+ if (args.length > 0 && !args[0].startsWith("-")) {
105
+ target = resolve(typedFrom, args.shift());
106
+ } else {
107
+ let n = 1;
108
+ while (existsSync(join(agentsDir, `new-${n}`))) n++;
109
+ target = join(agentsDir, `new-${n}`);
110
+ }
111
+
112
+ // Check the folder before creating anything, from its nearest existing parent.
113
+ let existing = target;
114
+ while (!existsSync(existing)) existing = dirname(existing);
115
+ existing = realpathSync(existing);
116
+ const inside = (parent, child) => {
117
+ const rel = relative(parent, child);
118
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
119
+ };
120
+ if (inside(realpathSync(root), existing)) fail(`${target} is inside the Operator package; choose a folder outside it`);
121
+ // A folder inside another repository would nest the agent's repository there. The agent's own repository
122
+ // is fine: that's how a build resumes.
123
+ let top;
124
+ try {
125
+ top = execFileSync("git", ["-C", existing, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
126
+ } catch {}
127
+ if (top) {
128
+ top = realpathSync(top);
129
+ if (!existsSync(target) || realpathSync(target) !== top) {
130
+ fail(`${target} is inside the git repository ${top}; choose a folder outside it, or the agent's own repository to resume`);
131
+ }
132
+ }
133
+
134
+ mkdirSync(target, { recursive: true });
135
+ target = realpathSync(target);
136
+ // The local equivalent of the cloud's workspace: files/ for the documents the owner shares (the documents tool
137
+ // opens files only inside the build folder) and outputs/ for what the agent produces, both beside the agent's
138
+ // repository, not in it. local-workspace.mjs tells the agent where they are.
139
+ for (const dir of ["files", "outputs"]) mkdirSync(join(target, dir), { recursive: true });
140
+ console.error(`introspection-operator: put the documents you'll share in ${join(target, "files")}`);
141
+ console.error(`introspection-operator: starting in ${target}`);
142
+ // Pi would load AGENTS.md and CLAUDE.md from this folder and every folder above it. Those are written for
143
+ // coding agents and can describe other agents, which build must not copy; the Recipe brings its own.
144
+ const result = spawnSync("pi", ["--recipe", root, "--agent", "build", "--no-context-files", "-e", join(root, "bin", "local-workspace.mjs"), ...args], {
145
+ cwd: target,
146
+ env,
147
+ stdio: "inherit",
148
+ shell: process.platform === "win32",
149
+ });
150
+ process.exit(result.status ?? 1);
@@ -0,0 +1,62 @@
1
+ // A Pi extension the launcher loads (`pi -e`) for a local build. It tells the build agent where the
2
+ // workspace's folders are, as the cloud host does for every session (introspection-cloud
3
+ // apps/runtime-agent/runtime/resources/context.ts, "Managed workspace"), so the Recipe itself says the same
4
+ // thing locally and in the cloud and holds no local paths.
5
+ //
6
+ // It adds a message to the conversation rather than changing the system prompt: with a root SYSTEM.md, the
7
+ // Recipes extension replaces Pi's whole prompt, so appended text (`--append-system-prompt`, or a
8
+ // before_agent_start handler that runs first) never reaches the model. The message is added once per session,
9
+ // so continuing a session with `-c` doesn't repeat it.
10
+ import { delimiter, join } from "node:path";
11
+
12
+ const TYPE = "operator-workspace";
13
+
14
+ // Commands the agent runs must not inherit the build agent's own launch. The Recipes extension exports
15
+ // PI_AGENT_NAME (`build`) for shell tools, and a Recipe launch reads it as its default agent, so
16
+ // `introspection local` in the agent's repository looks for an agent named `build` there and doesn't start.
17
+ // So every bash command starts by unsetting it. Windows shells are left as they are.
18
+ //
19
+ // The same goes for the Operator's own Python: the launcher starts Pi with it (VIRTUAL_ENV, its bin first on
20
+ // PATH) for the Operator's documents tool. A Recipe run with `introspection local` that inherits it uses the
21
+ // Operator's environment, so it passes without one of its own and fails for its owner and in the cloud.
22
+ const operatorPython = process.env.VIRTUAL_ENV;
23
+ const UNSET = ["PI_AGENT_NAME", "VIRTUAL_ENV"];
24
+
25
+ function withoutOperatorEnv(command) {
26
+ if (process.platform === "win32") return command;
27
+ const unset = `unset ${UNSET.join(" ")}`;
28
+ if (!operatorPython) return `${unset}\n${command}`;
29
+ const bin = join(operatorPython, "bin");
30
+ const path = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== bin).join(delimiter);
31
+ return `${unset}; export PATH='${path.replaceAll("'", `'\\''`)}'\n${command}`;
32
+ }
33
+
34
+ export default function localWorkspace(pi) {
35
+ pi.on("tool_call", (event) => {
36
+ if (event.toolName === "bash" && typeof event.input?.command === "string") {
37
+ event.input.command = withoutOperatorEnv(event.input.command);
38
+ }
39
+ });
40
+ pi.on("before_agent_start", (_event, ctx) => {
41
+ if (ctx.sessionManager.getEntries().some((entry) => entry.type === "custom_message" && entry.customType === TYPE)) return;
42
+ const root = ctx.cwd ?? process.cwd();
43
+ const files = join(root, "files");
44
+ const outputs = join(root, "outputs");
45
+ const content = [
46
+ "## Managed workspace",
47
+ "",
48
+ "<workspace>",
49
+ ` <inputs path="${files}" access="read_write" persistence="local" />`,
50
+ ` <outputs path="${outputs}" persistence="local" />`,
51
+ " Create the agent's repository in this folder, beside files/ and outputs/.",
52
+ "</workspace>",
53
+ `This build runs on the owner's computer, and the documents tool opens files only inside ${root}. ` +
54
+ `The owner shares a document by giving you its path, or by putting it in ${files}: ask for it that way ` +
55
+ `("send me the path to it, or put it in ${files}"), not as an upload. ` +
56
+ "When the owner gives you a path to a file outside files/, copy it into files/ first and read the copy; " +
57
+ "don't try the original path, which the tool refuses. Never copy a file straight into the agent's repository. " +
58
+ "What belongs in the Recipe is added from files/.",
59
+ ].join("\n");
60
+ return { message: { customType: TYPE, content, display: false } };
61
+ });
62
+ }
@@ -0,0 +1,223 @@
1
+ // Reads a case run's Pi event stream (`--mode json`) as it arrives and says what the run is doing: finished,
2
+ // didn't start, failed, working (and on what), a tool that hasn't returned, a model that went quiet, or a loop.
3
+ // Pi's events carry no times, so the reader stamps each one when it arrives.
4
+
5
+ export const LIMITS = {
6
+ // A tool that hasn't returned in this long is hung.
7
+ toolHungMs: 15 * 60_000,
8
+ // No event at all for this long, outside a tool, means the model stalled.
9
+ stalledMs: 10 * 60_000,
10
+ // The same call with the same arguments this often, or the same error this often, is a loop.
11
+ sameCall: 4,
12
+ sameError: 3,
13
+ };
14
+
15
+ export function createRunReader() {
16
+ const state = {
17
+ events: 0,
18
+ lastEventAt: undefined,
19
+ turns: 0,
20
+ outputTokens: 0,
21
+ message: undefined, // the assistant message being generated: { kind, since, chars, tokens }
22
+ tools: new Map(), // running tools by call id: { name, args, since }
23
+ toolCounts: {},
24
+ calls: new Map(), // "name args" -> count
25
+ errors: [], // { tool, text }
26
+ errorCounts: new Map(),
27
+ retries: [],
28
+ finalText: undefined,
29
+ modelError: undefined,
30
+ ended: false,
31
+ };
32
+ let partial = "";
33
+
34
+ function onEvent(event, now) {
35
+ state.events++;
36
+ state.lastEventAt = now;
37
+ switch (event.type) {
38
+ case "turn_start":
39
+ state.turns++;
40
+ break;
41
+ case "message_start":
42
+ if (event.message?.role === "assistant") state.message = { kind: "waiting for the model", since: now, chars: 0, tokens: 0 };
43
+ break;
44
+ case "message_update": {
45
+ const delta = event.assistantMessageEvent;
46
+ const kind = { thinking_delta: "thinking", text_delta: "writing its reply", toolcall_delta: "writing a tool call" }[delta?.type];
47
+ if (!state.message) state.message = { kind: "waiting for the model", since: now, chars: 0, tokens: 0 };
48
+ if (kind && kind !== state.message.kind) Object.assign(state.message, { kind, since: now, chars: 0 });
49
+ if (typeof delta?.delta === "string") state.message.chars += delta.delta.length;
50
+ if (typeof event.usage?.output === "number") state.message.tokens = event.usage.output;
51
+ break;
52
+ }
53
+ case "message_end": {
54
+ const message = event.message;
55
+ if (message?.role !== "assistant") break;
56
+ state.outputTokens += message.usage?.output ?? state.message?.tokens ?? 0;
57
+ state.message = undefined;
58
+ const text = textOf(message.content);
59
+ if (text) state.finalText = text;
60
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
61
+ state.modelError = message.errorMessage ?? message.stopReason;
62
+ } else if (message.stopReason) {
63
+ state.modelError = undefined;
64
+ }
65
+ break;
66
+ }
67
+ case "tool_execution_start":
68
+ state.tools.set(event.toolCallId, { name: event.toolName, args: event.args, since: now });
69
+ state.toolCounts[event.toolName] = (state.toolCounts[event.toolName] ?? 0) + 1;
70
+ {
71
+ const key = `${event.toolName} ${JSON.stringify(event.args ?? {})}`;
72
+ state.calls.set(key, (state.calls.get(key) ?? 0) + 1);
73
+ }
74
+ break;
75
+ case "tool_execution_end":
76
+ state.tools.delete(event.toolCallId);
77
+ if (event.isError) {
78
+ const text = firstLine(textOf(event.result?.content));
79
+ state.errors.push({ tool: event.toolName, text });
80
+ const key = `${event.toolName}: ${text}`;
81
+ state.errorCounts.set(key, (state.errorCounts.get(key) ?? 0) + 1);
82
+ }
83
+ break;
84
+ case "auto_retry_start":
85
+ state.retries.push(event.errorMessage ?? `attempt ${event.attempt}`);
86
+ break;
87
+ case "agent_end":
88
+ state.ended = true;
89
+ break;
90
+ }
91
+ }
92
+
93
+ return {
94
+ state,
95
+ // Feed raw stdout; returns the complete lines, for writing to events.jsonl unchanged.
96
+ feed(chunk, now = Date.now()) {
97
+ partial += chunk;
98
+ const lines = partial.split("\n");
99
+ partial = lines.pop();
100
+ for (const line of lines) {
101
+ if (!line.trim()) continue;
102
+ try {
103
+ onEvent(JSON.parse(line), now);
104
+ } catch {
105
+ // Not an event (a warning printed to stdout): keep it in the file, ignore it here.
106
+ }
107
+ }
108
+ return lines;
109
+ },
110
+ };
111
+ }
112
+
113
+ // The run's state in one verdict and a few lines of evidence.
114
+ // run: { started, budgetMs, exit: { code, signal } | undefined, stopReason, stderr, outputs: [{ path, bytes }] }
115
+ export function describe(state, run, now = Date.now()) {
116
+ const lines = [];
117
+ const elapsed = now - run.started;
118
+ const tools = Object.entries(state.toolCounts).map(([name, n]) => `${name} ${n}`).join(", ") || "none";
119
+ const progress = `${minutes(elapsed)}, turn ${state.turns}, ${state.outputTokens + (state.message?.tokens ?? 0)} output tokens, tool calls: ${tools}`;
120
+ const loop = findLoop(state);
121
+ let verdict;
122
+ let problem;
123
+
124
+ if (run.exit) {
125
+ if (run.stopReason) {
126
+ verdict = `stopped: ${run.stopReason}`;
127
+ problem = run.stopProblem;
128
+ } else if (run.exit.code !== 0 && state.turns === 0) {
129
+ verdict = "didn't start";
130
+ problem = "start";
131
+ } else if (run.exit.code !== 0 || state.modelError) {
132
+ verdict = `failed${state.modelError ? `: the model returned an error (${firstLine(state.modelError)})` : ` with exit code ${run.exit.code ?? run.exit.signal}`}`;
133
+ problem = "failed";
134
+ } else if (run.outputs.length === 0 && !state.finalText) {
135
+ verdict = "finished with no output: no reply, and outputs/ is empty";
136
+ problem = "no-output";
137
+ } else if (run.outputs.length === 0) {
138
+ verdict = "finished: replied, and wrote no files to outputs/";
139
+ } else {
140
+ verdict = "finished";
141
+ }
142
+ } else {
143
+ const hung = longestTool(state, now);
144
+ if (hung && now - hung.since >= LIMITS.toolHungMs) {
145
+ verdict = `tool hung: ${hung.name} has run ${minutes(now - hung.since)} (${summarize(hung.args)})`;
146
+ problem = "tool-hung";
147
+ } else if (!hung && state.lastEventAt !== undefined && now - state.lastEventAt >= LIMITS.stalledMs) {
148
+ verdict = `model stalled: no event for ${minutes(now - state.lastEventAt)}`;
149
+ problem = "stalled";
150
+ } else if (state.lastEventAt === undefined && elapsed >= LIMITS.stalledMs) {
151
+ verdict = `no events after ${minutes(elapsed)}: the run hasn't started a session`;
152
+ problem = "stalled";
153
+ } else if (run.budgetMs && elapsed >= run.budgetMs) {
154
+ verdict = `over budget while working (${minutes(run.budgetMs)})`;
155
+ problem = "over-budget";
156
+ } else if (loop) {
157
+ verdict = `looping: ${loop}`;
158
+ problem = "looping";
159
+ } else if (hung) {
160
+ verdict = `working: running ${hung.name} for ${minutes(now - hung.since)} (${summarize(hung.args)})`;
161
+ } else if (state.message) {
162
+ verdict = `working: ${state.message.kind} for ${minutes(now - state.message.since)}${state.message.chars ? ` (${state.message.chars} characters so far)` : ""}`;
163
+ } else {
164
+ verdict = "working";
165
+ }
166
+ }
167
+
168
+ lines.push(verdict);
169
+ if (state.modelError) {
170
+ lines.push("this is the model provider, not the agent: fix access, credentials or quota and run it again; the agent keeps the model it was designed with");
171
+ }
172
+ lines.push(`progress: ${progress}`);
173
+ if (loop && problem !== "looping") lines.push(`repeating: ${loop}`);
174
+ if (state.retries.length) lines.push(`model retries: ${state.retries.length} (last: ${firstLine(state.retries.at(-1))})`);
175
+ if (state.errors.length) {
176
+ lines.push(`tool errors: ${state.errors.length}`);
177
+ for (const error of state.errors.slice(-5)) lines.push(` ${error.tool}: ${error.text}`);
178
+ }
179
+ if (run.exit) {
180
+ lines.push(`outputs: ${run.outputs.length ? run.outputs.map((o) => `${o.path} (${o.bytes} bytes)`).join(", ") : "none"}`);
181
+ if (state.finalText) lines.push(`final reply: ${truncate(state.finalText, 1200)}`);
182
+ if (run.exit.code !== 0 && run.stderr) lines.push(`stderr (last lines):\n${tail(run.stderr, 15)}`);
183
+ }
184
+ return { verdict, problem, text: lines.join("\n") };
185
+ }
186
+
187
+ function longestTool(state, now) {
188
+ let longest;
189
+ for (const tool of state.tools.values()) if (!longest || tool.since < longest.since) longest = tool;
190
+ return longest && now >= longest.since ? longest : undefined;
191
+ }
192
+
193
+ function findLoop(state) {
194
+ for (const [key, n] of state.calls) if (n >= LIMITS.sameCall) return `the same call ${n} times: ${truncate(key, 200)}`;
195
+ for (const [key, n] of state.errorCounts) if (n >= LIMITS.sameError) return `the same error ${n} times: ${truncate(key, 200)}`;
196
+ return undefined;
197
+ }
198
+
199
+ function textOf(content) {
200
+ if (typeof content === "string") return content;
201
+ return (Array.isArray(content) ? content : []).filter((c) => c?.type === "text").map((c) => c.text).join("\n").trim();
202
+ }
203
+
204
+ function summarize(args) {
205
+ return truncate(JSON.stringify(args ?? {}), 160);
206
+ }
207
+
208
+ function firstLine(text) {
209
+ return truncate(String(text ?? "").trim().split("\n")[0], 200);
210
+ }
211
+
212
+ function truncate(text, max) {
213
+ return text.length > max ? `${text.slice(0, max)}…` : text;
214
+ }
215
+
216
+ function tail(text, n) {
217
+ return text.trimEnd().split("\n").slice(-n).join("\n");
218
+ }
219
+
220
+ export function minutes(ms) {
221
+ const s = Math.round(ms / 1000);
222
+ return s < 90 ? `${s}s` : `${Math.floor(s / 60)} min ${s % 60}s`;
223
+ }
@@ -0,0 +1,88 @@
1
+ // Tools the build agent runs an agent's cases with: run_case starts one run of the Recipe being built in the
2
+ // background, case_status says what it's doing, stop_case ends it. The runs themselves are in runs.mjs, and
3
+ // what their events mean in events.mjs.
4
+ import { relative } from "node:path";
5
+ import { Type } from "typebox";
6
+ import { minutes } from "./events.mjs";
7
+ import { createCaseRuns, DEFAULT_BUDGET_MINUTES, MAX_WAIT_SECONDS } from "./runs.mjs";
8
+
9
+ function text(value) {
10
+ return { content: [{ type: "text", text: value }], details: {} };
11
+ }
12
+
13
+ export default function caseRuns(pi) {
14
+ const caseRuns = createCaseRuns();
15
+
16
+ function find(id) {
17
+ const run = caseRuns.runs.get(id);
18
+ if (!run) throw new Error(`no case run ${id} in this session; runs started: ${[...caseRuns.runs.keys()].join(", ") || "none"}`);
19
+ return run;
20
+ }
21
+
22
+ pi.registerTool({
23
+ name: "run_case",
24
+ label: "Run case",
25
+ description:
26
+ "Run the agent being built on one case, the way a real request runs, in the background. The run gets its own folder runs/<case>-<n>/ in the workspace, with the case's input files in its files/ and an empty outputs/, and starts `introspection local --work-dir <repository> -p <prompt>` there. Its session and event stream (events.jsonl) stay in that folder. Returns the run's id at once; follow it with case_status.",
27
+ parameters: Type.Object({
28
+ case: Type.String({ description: "The case's id, such as C1; names the run's folder." }),
29
+ prompt: Type.String({ description: "The request, holding only what a real request brings." }),
30
+ repository: Type.String({ description: "The agent's repository, relative to the workspace." }),
31
+ files: Type.Optional(Type.Array(Type.String(), { description: "Input files for the run's files/, relative to the workspace or absolute." })),
32
+ agent: Type.Optional(Type.String({ description: "The Recipe agent to run, when it isn't the default." })),
33
+ budget_minutes: Type.Optional(Type.Number({ description: `Stop the run after this many minutes even while it's working (default ${DEFAULT_BUDGET_MINUTES}).` })),
34
+ env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Environment the agent's own runtime needs, such as its Python environment's VIRTUAL_ENV." })),
35
+ }),
36
+ async execute(_id, params, _signal, _onUpdate, ctx) {
37
+ const cwd = ctx?.cwd ?? process.cwd();
38
+ const run = caseRuns.start({
39
+ cwd,
40
+ caseId: params.case,
41
+ prompt: params.prompt,
42
+ repository: params.repository,
43
+ files: params.files,
44
+ agent: params.agent,
45
+ budgetMinutes: params.budget_minutes,
46
+ env: params.env,
47
+ });
48
+ return text(`Started ${run.id} in ${relative(cwd, run.dir)}/ (budget ${minutes(run.budgetMs)}). Check it with case_status ${run.id}; pass wait_seconds to wait for it.`);
49
+ },
50
+ });
51
+
52
+ pi.registerTool({
53
+ name: "case_status",
54
+ label: "Case status",
55
+ description:
56
+ "What a case run is doing, from its events: finished (with its outputs and final reply), didn't start, failed, working (thinking, writing, or which tool for how long), tool hung, model stalled, looping, or stopped and why. With wait_seconds it returns when the run ends, when a problem shows, or when the wait is over.",
57
+ parameters: Type.Object({
58
+ id: Type.String({ description: "The run's id from run_case, such as C1-1." }),
59
+ wait_seconds: Type.Optional(Type.Number({ description: `Wait up to this long (at most ${MAX_WAIT_SECONDS}).` })),
60
+ }),
61
+ async execute(_id, params, signal) {
62
+ const run = find(params.id);
63
+ if (params.wait_seconds) await caseRuns.wait(run, params.wait_seconds, signal);
64
+ return text(`${run.id}: ${caseRuns.status(run).text}`);
65
+ },
66
+ });
67
+
68
+ pi.registerTool({
69
+ name: "stop_case",
70
+ label: "Stop case",
71
+ description: "Stop a case run that is still going, and say what it was doing when stopped.",
72
+ parameters: Type.Object({
73
+ id: Type.String({ description: "The run's id from run_case." }),
74
+ reason: Type.Optional(Type.String({ description: "Why it's stopped, recorded in its run.json." })),
75
+ }),
76
+ async execute(_id, params) {
77
+ const run = find(params.id);
78
+ const before = caseRuns.status(run).verdict;
79
+ caseRuns.stop(run, params.reason ?? `stopped by the build agent while ${before}`, "stopped");
80
+ await caseRuns.wait(run, 15);
81
+ return text(`${run.id}: ${caseRuns.status(run).text}`);
82
+ },
83
+ });
84
+
85
+ pi.on("session_shutdown", async () => {
86
+ await caseRuns.stopAll();
87
+ });
88
+ }