@introspection-ai/operator 0.1.1 → 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 +6 -4
- package/SYSTEM.md +1 -1
- package/agents/build.yaml +6 -4
- package/bin/introspection-operator.js +33 -1
- package/bin/local-workspace.mjs +62 -0
- package/extensions/case-runs/events.mjs +223 -0
- package/extensions/case-runs/index.mjs +88 -0
- package/extensions/case-runs/runs.mjs +191 -0
- package/package.json +22 -3
- package/python/pyproject.toml +17 -0
- package/python/uv.lock +369 -0
- package/skills/build-brainstorm/SKILL.md +3 -3
- package/skills/build-brainstorm/references/cases.md +1 -1
- package/skills/build-draft/SKILL.md +5 -5
- package/skills/build-draft/references/documents.md +1 -1
- package/skills/build-draft/references/recipe-layout.md +9 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ The Operator is the agent that builds, deploys and improves other agents (Recipe
|
|
|
11
11
|
|
|
12
12
|
## Run build
|
|
13
13
|
|
|
14
|
-
You need Node 24 and credentials for the model in `agents/build.yaml` (sign in once with `/login` in Pi).
|
|
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).
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
npm i -g @introspection-ai/cli && introspection setup # once: Pi and the Recipes release it needs
|
|
@@ -20,9 +20,11 @@ npx @introspection-ai/operator ~/work/my-agent # start or resume a buil
|
|
|
20
20
|
npx @introspection-ai/operator ~/work/my-agent --model <model> # flags after the folder go to Pi
|
|
21
21
|
```
|
|
22
22
|
|
|
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`) 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.
|
|
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
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
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.
|
|
26
28
|
- **Versions:** it checks that Pi's `@introspection-ai/recipes` satisfies the `peerDependencies` range, and says to run `introspection upgrade` when it doesn't.
|
|
27
29
|
|
|
28
30
|
Set `INTROSPECTION_AGENTS_DIR` to put new builds somewhere other than `~/.introspection/agents`.
|
|
@@ -31,7 +33,7 @@ In a checkout of this repository, `pnpm build` runs the same launcher, and insta
|
|
|
31
33
|
|
|
32
34
|
## Package
|
|
33
35
|
|
|
34
|
-
`@introspection-ai/operator` is the npm package published from this repository's root. It contains the Recipe a person runs (`SYSTEM.md`, `agents
|
|
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.
|
|
35
37
|
|
|
36
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:
|
|
37
39
|
|
|
@@ -43,7 +45,7 @@ Skills are grouped by the stage of an agent's life (see [`docs/DESIGN.md`](docs/
|
|
|
43
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` |
|
|
44
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 |
|
|
45
47
|
|
|
46
|
-
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` and `bin/introspection-operator.js`) 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.
|
|
47
49
|
|
|
48
50
|
## License
|
|
49
51
|
|
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,
|
|
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
|
-
|
|
43
|
-
don't ask permission or ask for a summary
|
|
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.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Build treats the folder it starts in as the owner's workspace: it creates the agent's repository
|
|
11
11
|
// there and resumes from what it finds, so it must not start inside this package or another repository.
|
|
12
12
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
13
14
|
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
14
15
|
import { homedir } from "node:os";
|
|
15
16
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -70,6 +71,32 @@ if (min) {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
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
|
+
|
|
73
100
|
// The folder: the first argument when it isn't a flag, else the next new-<n>. A relative folder resolves
|
|
74
101
|
// from where the command was typed (pnpm runs scripts from the package root, so INIT_CWD first).
|
|
75
102
|
const typedFrom = process.env.INIT_CWD || process.cwd();
|
|
@@ -106,10 +133,15 @@ if (top) {
|
|
|
106
133
|
|
|
107
134
|
mkdirSync(target, { recursive: true });
|
|
108
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")}`);
|
|
109
141
|
console.error(`introspection-operator: starting in ${target}`);
|
|
110
142
|
// Pi would load AGENTS.md and CLAUDE.md from this folder and every folder above it. Those are written for
|
|
111
143
|
// coding agents and can describe other agents, which build must not copy; the Recipe brings its own.
|
|
112
|
-
const result = spawnSync("pi", ["--recipe", root, "--agent", "build", "--no-context-files", ...args], {
|
|
144
|
+
const result = spawnSync("pi", ["--recipe", root, "--agent", "build", "--no-context-files", "-e", join(root, "bin", "local-workspace.mjs"), ...args], {
|
|
113
145
|
cwd: target,
|
|
114
146
|
env,
|
|
115
147
|
stdio: "inherit",
|
|
@@ -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
|
+
}
|