@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.
@@ -0,0 +1,191 @@
1
+ // Runs of the agent being built, one per case, in the background: the part of the case-run tools that doesn't
2
+ // need Pi, so it can be tested on its own.
3
+ //
4
+ // A bash call that runs `introspection local` and is killed at its time limit leaves only "timed out": a run
5
+ // that was still thinking looks the same as one stuck on a tool. Here each run keeps its session and its event
6
+ // stream in its own folder, and the events say which it is. A run is stopped only when a tool hangs, the model
7
+ // goes quiet, or its time budget runs out, and the status says which.
8
+ import { spawn } from "node:child_process";
9
+ import { copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
10
+ import { basename, delimiter, isAbsolute, join, relative, resolve } from "node:path";
11
+ import { createRunReader, describe } from "./events.mjs";
12
+
13
+ export const DEFAULT_BUDGET_MINUTES = 45;
14
+ export const MAX_WAIT_SECONDS = 1800;
15
+ const WATCH_MS = 15_000;
16
+ // Problems that end a run on their own; a loop is reported, and the build agent decides.
17
+ const STOPS = new Set(["tool-hung", "stalled", "over-budget"]);
18
+
19
+ // The run must not inherit the Operator's own launch: its agent name and Recipe (a Recipe launch reads them as
20
+ // defaults) and its Python environment.
21
+ export function runEnvironment(base, extra = {}) {
22
+ const env = { ...base };
23
+ delete env.PI_AGENT_NAME;
24
+ delete env.PI_RECIPE_DIR;
25
+ if (env.VIRTUAL_ENV) {
26
+ const bin = join(env.VIRTUAL_ENV, process.platform === "win32" ? "Scripts" : "bin");
27
+ env.PATH = (env.PATH ?? "").split(delimiter).filter((dir) => dir !== bin).join(delimiter);
28
+ delete env.VIRTUAL_ENV;
29
+ }
30
+ Object.assign(env, extra);
31
+ if (extra.VIRTUAL_ENV) {
32
+ const bin = join(extra.VIRTUAL_ENV, process.platform === "win32" ? "Scripts" : "bin");
33
+ env.PATH = [bin, env.PATH].filter(Boolean).join(delimiter);
34
+ }
35
+ return env;
36
+ }
37
+
38
+ export function createCaseRuns({ command = "introspection", env: baseEnv = process.env, watchMs = WATCH_MS, shutdownGraceMs = 5_000 } = {}) {
39
+ const runs = new Map();
40
+
41
+ function outputsOf(run) {
42
+ const root = join(run.dir, "outputs");
43
+ const found = [];
44
+ const walk = (dir) => {
45
+ if (!existsSync(dir)) return;
46
+ for (const name of readdirSync(dir)) {
47
+ const path = join(dir, name);
48
+ const stat = statSync(path);
49
+ if (stat.isDirectory()) walk(path);
50
+ else found.push({ path: relative(run.cwd, path), bytes: stat.size });
51
+ }
52
+ };
53
+ walk(root);
54
+ return found;
55
+ }
56
+
57
+ function status(run, now = Date.now()) {
58
+ return describe(run.reader.state, { ...run, outputs: run.exit ? outputsOf(run) : [] }, now);
59
+ }
60
+
61
+ function save(run) {
62
+ const { id, case: caseId, prompt, repository, started, budgetMs, exit, stopReason } = run;
63
+ writeFileSync(join(run.dir, "run.json"), `${JSON.stringify({ id, case: caseId, prompt, repository, started: new Date(started).toISOString(), budgetMinutes: budgetMs / 60_000, exit, stopReason }, null, 2)}\n`);
64
+ }
65
+
66
+ // The run's whole process group: what the launcher started, too, even after the launcher has exited.
67
+ function kill(run, signal) {
68
+ try {
69
+ if (process.platform === "win32") run.child.kill(signal);
70
+ else process.kill(-run.child.pid, signal);
71
+ } catch {
72
+ // Already gone.
73
+ }
74
+ }
75
+
76
+ function stop(run, reason, problem) {
77
+ if (run.exit || run.stopping) return;
78
+ run.stopping = true;
79
+ run.stopReason = reason;
80
+ run.stopProblem = problem;
81
+ kill(run, "SIGTERM");
82
+ // A tool the run started may ignore SIGTERM.
83
+ setTimeout(() => kill(run, "SIGKILL"), 10_000).unref();
84
+ }
85
+
86
+ function start({ cwd, caseId, prompt, repository, files = [], agent, budgetMinutes = DEFAULT_BUDGET_MINUTES, env = {} }) {
87
+ const name = String(caseId).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "case";
88
+ const runsDir = join(cwd, "runs");
89
+ let n = 1;
90
+ while (existsSync(join(runsDir, `${name}-${n}`))) n++;
91
+ const id = `${name}-${n}`;
92
+ const dir = join(runsDir, id);
93
+ const repo = resolve(cwd, repository);
94
+ if (!existsSync(join(repo, "package.json"))) throw new Error(`${repository} is not the agent's repository: no package.json in ${repo}`);
95
+ const inputs = new Map();
96
+ for (const file of files) {
97
+ const from = resolve(cwd, file);
98
+ if (!existsSync(from)) throw new Error(`no file ${file}`);
99
+ const name = basename(from);
100
+ if (inputs.has(name)) throw new Error(`two input files are named ${name} (${inputs.get(name)} and ${file}); rename one, since both would be files/${name}`);
101
+ inputs.set(name, file);
102
+ }
103
+ for (const sub of ["files", "outputs", "session"]) mkdirSync(join(dir, sub), { recursive: true });
104
+ for (const [name, file] of inputs) copyFileSync(resolve(cwd, file), join(dir, "files", name));
105
+
106
+ const args = ["local", "--work-dir", repo, ...(agent ? ["--agent", agent] : []), "-p", prompt, "--mode", "json", "--", "--session-dir", join(dir, "session")];
107
+ const child = spawn(command, args, {
108
+ cwd: dir,
109
+ env: runEnvironment(baseEnv, env),
110
+ stdio: ["ignore", "pipe", "pipe"],
111
+ // Its own process group, so stopping it also stops the Pi it starts.
112
+ detached: process.platform !== "win32",
113
+ });
114
+ const run = {
115
+ id, case: caseId, prompt, repository: relative(cwd, repo) || ".", cwd, dir, child,
116
+ started: Date.now(), budgetMs: budgetMinutes * 60_000,
117
+ reader: createRunReader(), stderr: "", exit: undefined, waiters: new Set(),
118
+ };
119
+ runs.set(id, run);
120
+ const events = createWriteStream(join(dir, "events.jsonl"));
121
+ child.stdout.setEncoding("utf8");
122
+ child.stdout.on("data", (chunk) => {
123
+ for (const line of run.reader.feed(chunk)) events.write(`${line}\n`);
124
+ });
125
+ child.stderr.setEncoding("utf8");
126
+ child.stderr.on("data", (chunk) => {
127
+ run.stderr = (run.stderr + chunk).slice(-20_000);
128
+ });
129
+ const finish = (exit) => {
130
+ if (run.exit) return;
131
+ run.exit = exit;
132
+ clearInterval(run.watch);
133
+ events.end();
134
+ writeFileSync(join(dir, "stderr.log"), run.stderr);
135
+ save(run);
136
+ for (const wake of run.waiters) wake();
137
+ };
138
+ child.on("error", (err) => {
139
+ run.stderr += `\n${err.message}`;
140
+ finish({ code: 127, signal: null });
141
+ });
142
+ child.on("close", (code, signal) => finish({ code, signal }));
143
+ run.watch = setInterval(() => {
144
+ const { problem, verdict } = status(run);
145
+ if (STOPS.has(problem)) stop(run, verdict, problem);
146
+ for (const wake of run.waiters) wake();
147
+ }, watchMs);
148
+ run.watch.unref?.();
149
+ save(run);
150
+ return run;
151
+ }
152
+
153
+ async function wait(run, seconds, signal) {
154
+ const until = Date.now() + Math.min(Math.max(seconds, 0), MAX_WAIT_SECONDS) * 1000;
155
+ while (!run.exit && Date.now() < until && !signal?.aborted) {
156
+ const { problem, verdict } = status(run);
157
+ // A run that must stop is stopped now, and the wait goes on until it has; a loop is the caller's call.
158
+ if (STOPS.has(problem)) stop(run, verdict, problem);
159
+ else if (problem) break;
160
+ await new Promise((done) => {
161
+ const timer = setTimeout(finish, Math.min(5_000, until - Date.now()));
162
+ function finish() {
163
+ clearTimeout(timer);
164
+ run.waiters.delete(finish);
165
+ signal?.removeEventListener?.("abort", finish);
166
+ done();
167
+ }
168
+ run.waiters.add(finish);
169
+ signal?.addEventListener?.("abort", finish, { once: true });
170
+ });
171
+ }
172
+ }
173
+
174
+ // When the Operator session ends, nothing it started may outlive it: SIGTERM every run, give them a moment,
175
+ // then SIGKILL every run's group, finished or not, before returning.
176
+ async function stopAll() {
177
+ const all = [...runs.values()];
178
+ for (const run of all) stop(run, "the Operator session ended", "session-ended");
179
+ const open = all.filter((run) => !run.exit);
180
+ if (open.length) {
181
+ await Promise.race([
182
+ Promise.all(open.map((run) => new Promise((done) => (run.exit ? done() : run.child.once("close", done))))),
183
+ new Promise((done) => setTimeout(done, shutdownGraceMs)),
184
+ ]);
185
+ }
186
+ for (const run of all) kill(run, "SIGKILL");
187
+ }
188
+
189
+ return { runs, start, status, stop, wait, stopAll };
190
+ }
191
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@introspection-ai/operator",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "The Introspection Operator: a Recipe and skills that build an agent with its owner (brainstorm, draft and review).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -19,12 +19,18 @@
19
19
  "skills",
20
20
  "agents",
21
21
  "SYSTEM.md",
22
- "bin"
22
+ "bin",
23
+ "extensions",
24
+ "python/pyproject.toml",
25
+ "python/uv.lock"
23
26
  ],
24
27
  "publishConfig": {
25
28
  "access": "public"
26
29
  },
27
30
  "pi": {
31
+ "extensions": [
32
+ "extensions/case-runs"
33
+ ],
28
34
  "documents": [
29
35
  {
30
36
  "format": "docx"
@@ -32,7 +38,20 @@
32
38
  {
33
39
  "format": "pdf"
34
40
  }
35
- ]
41
+ ],
42
+ "runtime": {
43
+ "python": {
44
+ "project": "python",
45
+ "lockfile": "python/uv.lock",
46
+ "version": ">=3.12,<3.15",
47
+ "imports": [
48
+ "docx",
49
+ "pypdf",
50
+ "pypdfium2",
51
+ "PIL"
52
+ ]
53
+ }
54
+ }
36
55
  },
37
56
  "type": "module",
38
57
  "scripts": {
@@ -0,0 +1,17 @@
1
+ # The Python the Recipe's `documents` tool runs on: paper-docx for Word, and pypdf, pypdfium2 and pillow for
2
+ # PDF. Declared in package.json#pi.runtime.python; the launcher builds it with `uv sync --frozen`.
3
+ [project]
4
+ name = "introspection-operator-runtime"
5
+ version = "0.0.0"
6
+ requires-python = ">=3.12,<3.15"
7
+ dependencies = [
8
+ "paper-docx==0.2.0",
9
+ "pypdf[crypto]==6.21.4",
10
+ "pypdfium2==5.14.0",
11
+ "pillow==13.0.0",
12
+ ]
13
+
14
+ [tool.uv]
15
+ package = false
16
+ # paper-docx provides the `docx` module; python-docx would shadow it.
17
+ constraint-dependencies = ["python-docx<0"]