pi-agent-fleet 0.2.0 → 0.3.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 +6 -6
- package/src/canvas.ts +630 -0
- package/src/command.ts +247 -0
- package/src/controller.ts +296 -0
- package/src/dag.ts +14 -3
- package/src/edits.ts +107 -0
- package/src/fleet-store.ts +55 -0
- package/src/index.ts +8 -594
- package/src/insert.ts +90 -0
- package/src/model-resolution.ts +73 -0
- package/src/planner.ts +148 -0
- package/src/preferences.ts +103 -0
- package/src/prompts.ts +7 -0
- package/src/runner.ts +41 -8
- package/src/scheduler.ts +46 -8
- package/src/tools.ts +348 -0
- package/src/types.ts +7 -1
- package/src/ui.ts +51 -12
- package/src/viz.ts +3 -2
package/src/insert.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ActiveFleet } from "./controller.js";
|
|
4
|
+
import { validateFleetSpec } from "./dag.js";
|
|
5
|
+
import { resolveModelReference, type ModelRegistryLike } from "./model-resolution.js";
|
|
6
|
+
import { buildWorkerPrompt } from "./prompts.js";
|
|
7
|
+
import { writeState } from "./state.js";
|
|
8
|
+
|
|
9
|
+
export interface InsertResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
inserted?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function persistFleetJson(fleet: ActiveFleet): Promise<void> {
|
|
16
|
+
const path = join(fleet.fleetRoot, "fleet.json");
|
|
17
|
+
const tmp = join(fleet.fleetRoot, `.fleet.json.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
18
|
+
await writeFile(tmp, `${JSON.stringify(fleet.spec, null, 2)}\n`, "utf-8");
|
|
19
|
+
await rename(tmp, path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function insertWorkersSerialized(
|
|
23
|
+
fleet: ActiveFleet,
|
|
24
|
+
raw: unknown,
|
|
25
|
+
registry: ModelRegistryLike,
|
|
26
|
+
): Promise<InsertResult> {
|
|
27
|
+
if (fleet.state.status === "completed") {
|
|
28
|
+
return { ok: false, message: "fleet is completed; relaunch a node instead of inserting" };
|
|
29
|
+
}
|
|
30
|
+
const list = Array.isArray(raw) ? raw : (raw as { workers?: unknown[] } | null)?.workers;
|
|
31
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
32
|
+
return { ok: false, message: "no workers to insert (expected an array or { \"workers\": [...] })" };
|
|
33
|
+
}
|
|
34
|
+
const candidate = {
|
|
35
|
+
fleet_name: fleet.spec.fleet_name,
|
|
36
|
+
type: "dag",
|
|
37
|
+
config: fleet.spec.config,
|
|
38
|
+
workers: [...fleet.spec.workers, ...list],
|
|
39
|
+
};
|
|
40
|
+
const v = validateFleetSpec(candidate);
|
|
41
|
+
if (!v.ok) return { ok: false, message: `invalid node insertion:\n${v.errors.join("\n")}` };
|
|
42
|
+
const fresh = v.spec.workers.slice(fleet.spec.workers.length);
|
|
43
|
+
for (const w of fresh) {
|
|
44
|
+
if (w.model) {
|
|
45
|
+
const r = resolveModelReference(registry, w.model);
|
|
46
|
+
if (!r.ok) return { ok: false, message: `worker "${w.id}" model: ${r.error}` };
|
|
47
|
+
w.model = `${r.model.provider}/${r.model.id}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
for (const w of fresh) {
|
|
52
|
+
await mkdir(join(fleet.fleetRoot, "workers", w.id, "output"), { recursive: true });
|
|
53
|
+
const prompt = buildWorkerPrompt({ spec: v.spec, state: fleet.state, workerId: w.id, fleetRoot: fleet.fleetRoot });
|
|
54
|
+
await writeFile(join(fleet.fleetRoot, "workers", w.id, "prompt.md"), prompt, "utf-8");
|
|
55
|
+
}
|
|
56
|
+
if (!fleet.running) {
|
|
57
|
+
const nodes = { ...fleet.state.nodes };
|
|
58
|
+
for (const w of fresh) {
|
|
59
|
+
nodes[w.id] = { status: "pending" as const, turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] };
|
|
60
|
+
}
|
|
61
|
+
const next = { ...fleet.state, nodes };
|
|
62
|
+
await writeState(fleet.fleetRoot, next);
|
|
63
|
+
fleet.state = next;
|
|
64
|
+
}
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
return { ok: false, message: `insert failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
67
|
+
}
|
|
68
|
+
fleet.spec.workers.push(...fresh);
|
|
69
|
+
try {
|
|
70
|
+
await persistFleetJson(fleet);
|
|
71
|
+
} catch (e: unknown) {
|
|
72
|
+
fleet.spec.workers.length -= fresh.length;
|
|
73
|
+
return { ok: false, message: `insert failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
74
|
+
}
|
|
75
|
+
const ids = fresh.map((w) => w.id);
|
|
76
|
+
return { ok: true, message: `inserted ${ids.join(", ")}`, inserted: ids };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const insertQueues = new WeakMap<ActiveFleet, Promise<unknown>>();
|
|
80
|
+
|
|
81
|
+
export async function insertWorkers(
|
|
82
|
+
fleet: ActiveFleet,
|
|
83
|
+
raw: unknown,
|
|
84
|
+
registry: ModelRegistryLike,
|
|
85
|
+
): Promise<InsertResult> {
|
|
86
|
+
const prev = insertQueues.get(fleet) ?? Promise.resolve();
|
|
87
|
+
const run = prev.then(() => insertWorkersSerialized(fleet, raw, registry));
|
|
88
|
+
insertQueues.set(fleet, run.catch(() => {}));
|
|
89
|
+
return run;
|
|
90
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { FleetSpec } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export interface ModelRegistryLike {
|
|
5
|
+
getAvailable(): Model<Api>[];
|
|
6
|
+
getAll(): Model<Api>[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function aliasesFor(model: Model<Api>): string[] {
|
|
10
|
+
const extra = model as Model<Api> & { alias?: unknown; aliases?: unknown };
|
|
11
|
+
return [
|
|
12
|
+
model.id,
|
|
13
|
+
model.name,
|
|
14
|
+
...(typeof extra.alias === "string" ? [extra.alias] : []),
|
|
15
|
+
...(Array.isArray(extra.aliases) ? extra.aliases.filter((a): a is string => typeof a === "string") : []),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveModelReference(
|
|
20
|
+
registry: ModelRegistryLike,
|
|
21
|
+
ref: string,
|
|
22
|
+
): { ok: true; model: Model<Api> } | { ok: false; error: string } {
|
|
23
|
+
const models = registry.getAvailable();
|
|
24
|
+
const pool = models.length > 0 ? models : registry.getAll();
|
|
25
|
+
const needle = ref.toLowerCase();
|
|
26
|
+
const canonical = (m: Model<Api>) => `${m.provider}/${m.id}`.toLowerCase();
|
|
27
|
+
const byAlias = (m: Model<Api>, pred: (v: string) => boolean) => aliasesFor(m).some((a) => pred(a.toLowerCase()));
|
|
28
|
+
|
|
29
|
+
const tiers: Model<Api>[][] = [];
|
|
30
|
+
if (needle.includes("/")) {
|
|
31
|
+
tiers.push(pool.filter((m) => canonical(m) === needle));
|
|
32
|
+
const [provider, ...rest] = needle.split("/");
|
|
33
|
+
const alias = rest.join("/");
|
|
34
|
+
tiers.push(pool.filter((m) => m.provider.toLowerCase() === provider && byAlias(m, (a) => a === alias)));
|
|
35
|
+
}
|
|
36
|
+
tiers.push(pool.filter((m) => m.id.toLowerCase() === needle));
|
|
37
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a === needle)));
|
|
38
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a.startsWith(needle))));
|
|
39
|
+
tiers.push(pool.filter((m) => byAlias(m, (a) => a.includes(needle))));
|
|
40
|
+
|
|
41
|
+
for (const tier of tiers) {
|
|
42
|
+
const unique = [...new Map(tier.map((m) => [`${m.provider}/${m.id}`, m])).values()];
|
|
43
|
+
if (unique.length === 1) return { ok: true, model: unique[0] };
|
|
44
|
+
if (unique.length > 1) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: `model "${ref}" is ambiguous: ${unique.map((m) => `${m.provider}/${m.id}`).join(", ")}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, error: `model "${ref}" not found` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateFleetModels(
|
|
55
|
+
spec: FleetSpec,
|
|
56
|
+
registry: ModelRegistryLike,
|
|
57
|
+
): { ok: true } | { ok: false; errors: string[] } {
|
|
58
|
+
const errors: string[] = [];
|
|
59
|
+
const refs: Array<{ label: string; ref: string }> = [];
|
|
60
|
+
if (spec.config.model) refs.push({ label: "config.model", ref: spec.config.model });
|
|
61
|
+
for (const w of spec.workers) {
|
|
62
|
+
if (w.model) refs.push({ label: `worker "${w.id}" model`, ref: w.model });
|
|
63
|
+
}
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
for (const { label, ref } of refs) {
|
|
66
|
+
const key = `${label}\0${ref}`;
|
|
67
|
+
if (seen.has(key)) continue;
|
|
68
|
+
seen.add(key);
|
|
69
|
+
const r = resolveModelReference(registry, ref);
|
|
70
|
+
if (!r.ok) errors.push(`${label}: ${r.error}`);
|
|
71
|
+
}
|
|
72
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
73
|
+
}
|
package/src/planner.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runWorker, type SessionFactory, type WorkerEvent } from "./runner.js";
|
|
4
|
+
|
|
5
|
+
export const PLANNER_WORKER_ID = "planner";
|
|
6
|
+
|
|
7
|
+
export interface FleetDesignResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
error?: string;
|
|
10
|
+
draft?: unknown;
|
|
11
|
+
rationale?: string;
|
|
12
|
+
turns: number;
|
|
13
|
+
tokens: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function slugifyFleetName(requirements: string): string {
|
|
17
|
+
const slug = requirements
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
.slice(0, 40)
|
|
22
|
+
.replace(/^-+|-+$/g, "");
|
|
23
|
+
return slug.length > 0 ? slug : "fleet";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildPlannerPrompt(opts: { requirements: string; fleetName: string; plannerDir: string }): string {
|
|
27
|
+
const { requirements, fleetName, plannerDir } = opts;
|
|
28
|
+
return `# Fleet designer
|
|
29
|
+
|
|
30
|
+
You design a DAG-of-agents "fleet" for the pi fleet runner. You produce ONE definition file plus a rationale.
|
|
31
|
+
|
|
32
|
+
## Requirements from the user
|
|
33
|
+
|
|
34
|
+
${requirements}
|
|
35
|
+
|
|
36
|
+
## Your outputs (both REQUIRED)
|
|
37
|
+
|
|
38
|
+
- ${plannerDir}/output/fleet.json — the fleet definition. A single raw JSON object, no markdown fences, no commentary.
|
|
39
|
+
- ${plannerDir}/output/rationale.md — why this decomposition, what each node does, why the gate choice.
|
|
40
|
+
|
|
41
|
+
## Fleet JSON schema
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
"fleet_name": "${fleetName}",
|
|
45
|
+
"type": "dag",
|
|
46
|
+
"config": {
|
|
47
|
+
"max_concurrent": <integer >= 1, optional>,
|
|
48
|
+
"warn_cost_usd": <number >= 0, optional>,
|
|
49
|
+
"loop": { "gate": "reviewer" | "none", "max_iterations": <int >= 1>, "lgtm_count": <optional int, reviewer gate only> }
|
|
50
|
+
},
|
|
51
|
+
"workers": [
|
|
52
|
+
{
|
|
53
|
+
"id": "kebab-case",
|
|
54
|
+
"type": "research" | "code-run" | "reviewer" | "write" | "read-only",
|
|
55
|
+
"task": "full self-contained instructions for the worker",
|
|
56
|
+
"depends_on": ["upstream-worker-ids"],
|
|
57
|
+
"outputs": [{ "path": "output/<file> or repo-relative path", "kind": "markdown" | "file-exists" | "verdict" | "json" | "yaml", "required": true }],
|
|
58
|
+
"iterate": true,
|
|
59
|
+
"worktree": false
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
config, outputs, iterate, worktree, and loop are optional (defaults: max_concurrent 4, iterate true, worktree false).
|
|
65
|
+
|
|
66
|
+
## Hard rules (violations fail validation)
|
|
67
|
+
|
|
68
|
+
1. fleet_name and worker ids are kebab-case; ids unique.
|
|
69
|
+
2. depends_on references existing workers only; the graph must be acyclic.
|
|
70
|
+
3. Do NOT set "model" or "effort" fields anywhere — the runner assigns models and effort.
|
|
71
|
+
4. With gate "reviewer": exactly one worker declares an output of kind "verdict"; that worker must be a sink (nothing depends on it) and must iterate. Its task must instruct: the review file starts with exactly one of \`verdict: lgtm\` / \`verdict: iterate\` / \`verdict: escalate\`, followed by specific actionable per-worker feedback.
|
|
72
|
+
5. With gate "none": at least one worker must have iterate enabled (default true counts).
|
|
73
|
+
6. A worker with iterate: false may not depend on a worker that iterates.
|
|
74
|
+
7. Output paths: "output/..." resolves under the worker dir (use for notes, reports, verdicts); any other relative path is repo-relative (use for code edits). No absolute paths, no "..".
|
|
75
|
+
8. Worker types and tools: research (read/web/write), code-run (full coding tools), reviewer (read/write), write (read/write), read-only (read only).
|
|
76
|
+
|
|
77
|
+
## Design guidance
|
|
78
|
+
|
|
79
|
+
- Decompose into independent layer-0 research/analysis nodes that run in parallel, then synthesis/writer nodes, then optionally a reviewer gate.
|
|
80
|
+
- Prefer few high-value nodes over many trivial ones; 3-10 workers is typical.
|
|
81
|
+
- Each task must be self-contained: what to produce, where, format, constraints, done-criteria.
|
|
82
|
+
- Use a loop with gate "reviewer" only when iterative refinement against feedback makes sense; otherwise a single pass is cheaper.
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sanitizeDraft(draft: unknown): unknown {
|
|
87
|
+
if (typeof draft !== "object" || draft === null || Array.isArray(draft)) return draft;
|
|
88
|
+
const d = draft as Record<string, unknown>;
|
|
89
|
+
if (typeof d.config === "object" && d.config !== null && !Array.isArray(d.config)) {
|
|
90
|
+
const cfg = d.config as Record<string, unknown>;
|
|
91
|
+
delete cfg.model;
|
|
92
|
+
delete cfg.effort;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(d.workers)) {
|
|
95
|
+
for (const w of d.workers as Array<Record<string, unknown>>) {
|
|
96
|
+
if (typeof w === "object" && w !== null) {
|
|
97
|
+
delete w.model;
|
|
98
|
+
delete w.effort;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return d;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function runFleetDesign(opts: {
|
|
106
|
+
requirements: string;
|
|
107
|
+
fleetName: string;
|
|
108
|
+
designRoot: string;
|
|
109
|
+
repoCwd: string;
|
|
110
|
+
sessionFactory?: SessionFactory;
|
|
111
|
+
onEvent?: (e: WorkerEvent) => void;
|
|
112
|
+
}): Promise<FleetDesignResult> {
|
|
113
|
+
const plannerDir = join(opts.designRoot, "planner");
|
|
114
|
+
await mkdir(join(plannerDir, "output"), { recursive: true });
|
|
115
|
+
const prompt = buildPlannerPrompt({ requirements: opts.requirements, fleetName: opts.fleetName, plannerDir });
|
|
116
|
+
const res = await runWorker({
|
|
117
|
+
nodeId: PLANNER_WORKER_ID,
|
|
118
|
+
worker: { id: PLANNER_WORKER_ID, type: "write", task: "design a fleet DAG", depends_on: [], outputs: [] },
|
|
119
|
+
prompt,
|
|
120
|
+
repoCwd: opts.repoCwd,
|
|
121
|
+
sessionDir: plannerDir,
|
|
122
|
+
sessionFactory: opts.sessionFactory,
|
|
123
|
+
thinkingLevel: "medium",
|
|
124
|
+
onEvent: opts.onEvent ?? (() => {}),
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
return { ok: false, error: res.error ?? "planner session failed", turns: res.turns, tokens: res.tokens };
|
|
128
|
+
}
|
|
129
|
+
let raw: string;
|
|
130
|
+
try {
|
|
131
|
+
raw = await readFile(join(plannerDir, "output", "fleet.json"), "utf-8");
|
|
132
|
+
} catch {
|
|
133
|
+
return { ok: false, error: "planner did not write output/fleet.json", turns: res.turns, tokens: res.tokens };
|
|
134
|
+
}
|
|
135
|
+
let draft: unknown;
|
|
136
|
+
try {
|
|
137
|
+
draft = sanitizeDraft(JSON.parse(raw));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return { ok: false, error: `fleet.json is not valid JSON: ${(e as Error).message}`, turns: res.turns, tokens: res.tokens };
|
|
140
|
+
}
|
|
141
|
+
let rationale: string | undefined;
|
|
142
|
+
try {
|
|
143
|
+
rationale = await readFile(join(plannerDir, "output", "rationale.md"), "utf-8");
|
|
144
|
+
} catch {
|
|
145
|
+
// rationale is optional in the result
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, draft, rationale, turns: res.turns, tokens: res.tokens };
|
|
148
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { THINKING_LEVELS } from "./types.js";
|
|
5
|
+
import type { ThinkingLevelName } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface FleetPreferences {
|
|
8
|
+
max_concurrent?: number;
|
|
9
|
+
model?: string;
|
|
10
|
+
effort?: ThinkingLevelName;
|
|
11
|
+
warn_cost_usd?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const PREFERENCE_KEYS = ["max_concurrent", "model", "effort", "warn_cost_usd"] as const;
|
|
15
|
+
|
|
16
|
+
export function defaultPreferencesPath(): string {
|
|
17
|
+
return join(homedir(), ".pi", "agent", "fleet.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadPreferences(path: string = defaultPreferencesPath()): Promise<FleetPreferences> {
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(await readFile(path, "utf-8")) as Record<string, unknown>;
|
|
23
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
24
|
+
const prefs: FleetPreferences = {};
|
|
25
|
+
if (typeof raw.max_concurrent === "number" && Number.isInteger(raw.max_concurrent) && raw.max_concurrent >= 1) {
|
|
26
|
+
prefs.max_concurrent = raw.max_concurrent;
|
|
27
|
+
}
|
|
28
|
+
if (typeof raw.model === "string" && raw.model.length > 0) prefs.model = raw.model;
|
|
29
|
+
if (typeof raw.effort === "string" && THINKING_LEVELS.includes(raw.effort as ThinkingLevelName)) {
|
|
30
|
+
prefs.effort = raw.effort as ThinkingLevelName;
|
|
31
|
+
}
|
|
32
|
+
if (typeof raw.warn_cost_usd === "number" && Number.isFinite(raw.warn_cost_usd) && raw.warn_cost_usd >= 0) {
|
|
33
|
+
prefs.warn_cost_usd = raw.warn_cost_usd;
|
|
34
|
+
}
|
|
35
|
+
return prefs;
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function savePreferences(prefs: FleetPreferences, path: string = defaultPreferencesPath()): Promise<void> {
|
|
42
|
+
await mkdir(dirname(path), { recursive: true });
|
|
43
|
+
await writeFile(path, `${JSON.stringify(prefs, null, 2)}\n`, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mergeFleetConfig(raw: unknown, prefs: FleetPreferences): unknown {
|
|
47
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
|
|
48
|
+
const fleet = raw as Record<string, unknown>;
|
|
49
|
+
const cfg = (typeof fleet.config === "object" && fleet.config !== null && !Array.isArray(fleet.config))
|
|
50
|
+
? { ...(fleet.config as Record<string, unknown>) }
|
|
51
|
+
: {};
|
|
52
|
+
if (cfg.max_concurrent === undefined && prefs.max_concurrent !== undefined) cfg.max_concurrent = prefs.max_concurrent;
|
|
53
|
+
if (cfg.model === undefined && prefs.model !== undefined) cfg.model = prefs.model;
|
|
54
|
+
if (cfg.effort === undefined && prefs.effort !== undefined) cfg.effort = prefs.effort;
|
|
55
|
+
if (cfg.warn_cost_usd === undefined && prefs.warn_cost_usd !== undefined) cfg.warn_cost_usd = prefs.warn_cost_usd;
|
|
56
|
+
return { ...fleet, config: cfg };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validatePreferenceValue(
|
|
60
|
+
key: string,
|
|
61
|
+
value: string,
|
|
62
|
+
): { ok: true; parsed: number | string } | { ok: false; error: string } {
|
|
63
|
+
switch (key) {
|
|
64
|
+
case "max_concurrent": {
|
|
65
|
+
const n = Number(value);
|
|
66
|
+
if (!Number.isInteger(n) || n < 1) return { ok: false, error: "max_concurrent must be an integer >= 1" };
|
|
67
|
+
return { ok: true, parsed: n };
|
|
68
|
+
}
|
|
69
|
+
case "warn_cost_usd": {
|
|
70
|
+
const n = Number(value);
|
|
71
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, error: "warn_cost_usd must be a number >= 0" };
|
|
72
|
+
return { ok: true, parsed: n };
|
|
73
|
+
}
|
|
74
|
+
case "effort": {
|
|
75
|
+
if (!THINKING_LEVELS.includes(value as ThinkingLevelName)) {
|
|
76
|
+
return { ok: false, error: `effort must be one of ${THINKING_LEVELS.join(", ")}` };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, parsed: value };
|
|
79
|
+
}
|
|
80
|
+
case "model": {
|
|
81
|
+
if (value.trim().length === 0) return { ok: false, error: "model must be non-empty" };
|
|
82
|
+
return { ok: true, parsed: value.trim() };
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
return { ok: false, error: `unknown preference "${key}" (keys: ${PREFERENCE_KEYS.join(", ")})` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function setPreference(
|
|
90
|
+
prefs: FleetPreferences,
|
|
91
|
+
key: string,
|
|
92
|
+
value: string,
|
|
93
|
+
): { ok: true; prefs: FleetPreferences } | { ok: false; error: string } {
|
|
94
|
+
const v = validatePreferenceValue(key, value);
|
|
95
|
+
if (!v.ok) return v;
|
|
96
|
+
return { ok: true, prefs: { ...prefs, [key]: v.parsed } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function clearPreference(prefs: FleetPreferences, key: string): FleetPreferences {
|
|
100
|
+
const next: Record<string, unknown> = { ...prefs };
|
|
101
|
+
delete next[key];
|
|
102
|
+
return next as FleetPreferences;
|
|
103
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -87,6 +87,13 @@ export function buildWorkerPrompt(opts: {
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
out.push("## Requesting additional nodes (optional)", "");
|
|
91
|
+
out.push(`If you discover work this DAG cannot do as currently shaped, write ${workerDir}/output/node-requests.json:`, "");
|
|
92
|
+
out.push(
|
|
93
|
+
`{ "workers": [{ "id": "kebab-case", "type": "research|code-run|reviewer|write|read-only", "task": "self-contained instructions", "depends_on": ["${workerId}"], "outputs": [{ "path": "output/file.md", "kind": "markdown", "required": true }] }] }`,
|
|
94
|
+
"",
|
|
95
|
+
);
|
|
96
|
+
out.push("The runner validates the merged graph (unique ids, known deps, acyclic, loop-gate rules) and inserts valid nodes as pending. Invalid batches are rejected atomically and noted on your node. Do not set model or effort fields — the runner assigns them. Depend on your own id when the new node needs your outputs.", "");
|
|
90
97
|
out.push("## Your output obligations", "");
|
|
91
98
|
if (worker.outputs.length === 0) {
|
|
92
99
|
out.push("No declared outputs — completion is enough.", "");
|
package/src/runner.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
1
|
+
import { createAgentSession, SessionManager, type CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ThinkingLevelName, WorkerSpec } from "./types.js";
|
|
3
4
|
import { WORKER_TYPE_TOOLS } from "./types.js";
|
|
4
5
|
|
|
6
|
+
export type ThinkingLevelOption = NonNullable<CreateAgentSessionOptions["thinkingLevel"]>;
|
|
7
|
+
|
|
5
8
|
export type WorkerEvent =
|
|
6
9
|
| { type: "turn"; nodeId: string; turns: number }
|
|
7
10
|
| { type: "tokens"; nodeId: string; tokens: number }
|
|
@@ -21,6 +24,7 @@ export interface SessionOpts {
|
|
|
21
24
|
sessionDir: string;
|
|
22
25
|
tools: string[];
|
|
23
26
|
model?: string;
|
|
27
|
+
thinkingLevel?: ThinkingLevelName;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export type SessionFactory = (opts: SessionOpts) => Promise<AgentSessionLike>;
|
|
@@ -30,6 +34,7 @@ export const defaultSessionFactory: SessionFactory = async (opts) => {
|
|
|
30
34
|
cwd: opts.cwd,
|
|
31
35
|
tools: opts.tools,
|
|
32
36
|
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
37
|
+
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
33
38
|
});
|
|
34
39
|
return session as unknown as AgentSessionLike;
|
|
35
40
|
};
|
|
@@ -41,7 +46,9 @@ export interface RunWorkerOpts {
|
|
|
41
46
|
repoCwd: string;
|
|
42
47
|
sessionDir?: string;
|
|
43
48
|
onEvent: (e: WorkerEvent) => void;
|
|
49
|
+
onSession?: (session: AgentSessionLike) => void;
|
|
44
50
|
sessionFactory?: SessionFactory;
|
|
51
|
+
thinkingLevel?: ThinkingLevelName;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export interface RunWorkerResult {
|
|
@@ -54,12 +61,21 @@ export interface RunWorkerResult {
|
|
|
54
61
|
|
|
55
62
|
export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
56
63
|
const factory = opts.sessionFactory ?? defaultSessionFactory;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
let session: AgentSessionLike;
|
|
65
|
+
try {
|
|
66
|
+
session = await factory({
|
|
67
|
+
cwd: opts.repoCwd,
|
|
68
|
+
sessionDir: opts.sessionDir ?? opts.repoCwd,
|
|
69
|
+
tools: WORKER_TYPE_TOOLS[opts.worker.type],
|
|
70
|
+
model: opts.worker.model,
|
|
71
|
+
thinkingLevel: opts.thinkingLevel,
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
opts.onEvent({ type: "error", nodeId: opts.nodeId, message });
|
|
76
|
+
return { ok: false, turns: 0, tokens: 0, cost: 0, error: message };
|
|
77
|
+
}
|
|
78
|
+
opts.onSession?.(session);
|
|
63
79
|
let turns = 0;
|
|
64
80
|
let tokens = 0;
|
|
65
81
|
let cost = 0;
|
|
@@ -93,3 +109,20 @@ export async function runWorker(opts: RunWorkerOpts): Promise<RunWorkerResult> {
|
|
|
93
109
|
session.dispose();
|
|
94
110
|
}
|
|
95
111
|
}
|
|
112
|
+
|
|
113
|
+
export function sessionFactoryForModel(model: Model<Api>): SessionFactory {
|
|
114
|
+
return async (opts) => {
|
|
115
|
+
const { session } = await createAgentSession({
|
|
116
|
+
cwd: opts.cwd,
|
|
117
|
+
tools: opts.tools,
|
|
118
|
+
sessionManager: SessionManager.create(opts.cwd, opts.sessionDir),
|
|
119
|
+
model,
|
|
120
|
+
thinkingLevel: opts.thinkingLevel as ThinkingLevelOption,
|
|
121
|
+
});
|
|
122
|
+
return session as unknown as AgentSessionLike;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function workerWithResolvedModel(worker: WorkerSpec, model: Model<Api> | undefined): WorkerSpec {
|
|
127
|
+
return model ? { ...worker, model: `${model.provider}/${model.id}` } : worker;
|
|
128
|
+
}
|
package/src/scheduler.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { verifyOutputs } from "./contracts.js";
|
|
4
4
|
import { archiveIteration, initFleetState, patchNode, resetForIteration, snapshotIteration, writeState } from "./state.js";
|
|
5
5
|
import { TERMINAL_NODE_STATUSES } from "./types.js";
|
|
6
|
-
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict } from "./types.js";
|
|
6
|
+
import type { FleetSpec, FleetState, IterationSnapshot, NodeState, Verdict, WorkerSpec } from "./types.js";
|
|
7
7
|
|
|
8
8
|
export type SpawnFn = (nodeId: string) => Promise<{ ok: boolean; turns: number; tokens: number; cost?: number; error?: string }>;
|
|
9
9
|
|
|
@@ -13,8 +13,11 @@ export interface RunFleetOpts {
|
|
|
13
13
|
repoCwd: string | ((nodeId: string) => string);
|
|
14
14
|
spawn: SpawnFn;
|
|
15
15
|
onNodeChange?: (nodeId: string, s: NodeState) => void;
|
|
16
|
+
onNodeAdded?: (worker: WorkerSpec) => void | Promise<void>;
|
|
17
|
+
onNodeCompleted?: (nodeId: string) => Promise<string | undefined | void>;
|
|
16
18
|
killSwitch?: { killed: boolean };
|
|
17
19
|
pauseSwitch?: { paused: boolean };
|
|
20
|
+
nodeKills?: ReadonlySet<string>;
|
|
18
21
|
resumeFrom?: FleetState;
|
|
19
22
|
continuePass?: boolean;
|
|
20
23
|
onIterationEnd?: (snap: IterationSnapshot) => void;
|
|
@@ -24,7 +27,10 @@ export interface RunFleetOpts {
|
|
|
24
27
|
const FAILED: ReadonlySet<string> = new Set(["failed", "contract_failed", "killed", "blocked"]);
|
|
25
28
|
|
|
26
29
|
function allNodesTerminal(state: FleetState, spec: FleetSpec): boolean {
|
|
27
|
-
return spec.workers.every((w) =>
|
|
30
|
+
return spec.workers.every((w) => {
|
|
31
|
+
const n = state.nodes[w.id];
|
|
32
|
+
return !!n && TERMINAL_NODE_STATUSES.has(n.status);
|
|
33
|
+
});
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
async function cleanReplayOutputs(spec: FleetSpec, fleetRoot: string): Promise<void> {
|
|
@@ -68,18 +74,34 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
68
74
|
|
|
69
75
|
const runPass = async (): Promise<void> => {
|
|
70
76
|
while (true) {
|
|
77
|
+
// auto-initialize workers inserted into the spec after the run started
|
|
78
|
+
for (const w of spec.workers) {
|
|
79
|
+
if (!state.nodes[w.id]) {
|
|
80
|
+
state = {
|
|
81
|
+
...state,
|
|
82
|
+
nodes: {
|
|
83
|
+
...state.nodes,
|
|
84
|
+
[w.id]: { status: "pending", turns: 0, tokens: 0, cost_usd_estimate: 0, produced_outputs: [] },
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
await writeState(fleetRoot, state);
|
|
88
|
+
await opts.onNodeAdded?.(w);
|
|
89
|
+
opts.onNodeChange?.(w.id, state.nodes[w.id]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
71
92
|
// block nodes whose deps failed
|
|
72
93
|
for (const w of spec.workers) {
|
|
73
94
|
const n = state.nodes[w.id];
|
|
95
|
+
if (!n) continue;
|
|
74
96
|
if (n.status !== "pending" && n.status !== "ready") continue;
|
|
75
|
-
if (w.depends_on.some((d) => FAILED.has(state.nodes[d]
|
|
97
|
+
if (w.depends_on.some((d) => FAILED.has(state.nodes[d]?.status ?? ""))) {
|
|
76
98
|
await patch(w.id, { status: "blocked", ended_at: new Date().toISOString() });
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
if (opts.killSwitch?.killed) {
|
|
80
102
|
for (const w of spec.workers) {
|
|
81
103
|
const n = state.nodes[w.id];
|
|
82
|
-
if (!TERMINAL_NODE_STATUSES.has(n.status)) {
|
|
104
|
+
if (!n || !TERMINAL_NODE_STATUSES.has(n.status)) {
|
|
83
105
|
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
84
106
|
}
|
|
85
107
|
}
|
|
@@ -91,13 +113,22 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
91
113
|
for (const w of spec.workers) {
|
|
92
114
|
if (slots <= 0) break;
|
|
93
115
|
const n = state.nodes[w.id];
|
|
116
|
+
if (!n) continue;
|
|
94
117
|
if (n.status !== "pending" && n.status !== "ready") continue;
|
|
95
|
-
|
|
118
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
119
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString() });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const depsDone = w.depends_on.every((d) => state.nodes[d]?.status === "completed");
|
|
96
123
|
if (!depsDone) continue;
|
|
97
124
|
slots--;
|
|
98
125
|
await patch(w.id, { status: "running", started_at: new Date().toISOString() });
|
|
99
126
|
const p = opts.spawn(w.id).then(async (res) => {
|
|
100
127
|
if (opts.killSwitch?.killed) return;
|
|
128
|
+
if (opts.nodeKills?.has(w.id)) {
|
|
129
|
+
await patch(w.id, { status: "killed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
101
132
|
if (!res.ok) {
|
|
102
133
|
await patch(w.id, { status: "failed", ended_at: new Date().toISOString(), turns: res.turns, tokens: res.tokens, cost_usd_estimate: res.cost ?? 0 });
|
|
103
134
|
return;
|
|
@@ -116,12 +147,19 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
116
147
|
contract_result: contract,
|
|
117
148
|
produced_outputs: contract.checks.filter((c) => c.ok).map((c) => c.path),
|
|
118
149
|
});
|
|
150
|
+
if (contract.ok) {
|
|
151
|
+
const note = await opts.onNodeCompleted?.(w.id);
|
|
152
|
+
if (note) await patch(w.id, { status_note: note });
|
|
153
|
+
}
|
|
119
154
|
}).finally(() => running.delete(p));
|
|
120
155
|
running.add(p);
|
|
121
156
|
}
|
|
122
157
|
if (running.size > 0) {
|
|
123
158
|
await Promise.race(running);
|
|
124
|
-
} else if (spec.workers.every((w) =>
|
|
159
|
+
} else if (spec.workers.every((w) => {
|
|
160
|
+
const n = state.nodes[w.id];
|
|
161
|
+
return !!n && TERMINAL_NODE_STATUSES.has(n.status);
|
|
162
|
+
})) {
|
|
125
163
|
break;
|
|
126
164
|
}
|
|
127
165
|
}
|
|
@@ -131,7 +169,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
131
169
|
if (!loop) {
|
|
132
170
|
await runPass();
|
|
133
171
|
const anyFailed = spec.workers.some((w) =>
|
|
134
|
-
["failed", "contract_failed"].includes(state.nodes[w.id]
|
|
172
|
+
["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
|
|
135
173
|
const finalStatus = opts.killSwitch?.killed ? "killed" : anyFailed ? "failed" : "completed";
|
|
136
174
|
state = { ...state, status: finalStatus };
|
|
137
175
|
await writeState(fleetRoot, state);
|
|
@@ -181,7 +219,7 @@ export async function runFleet(opts: RunFleetOpts): Promise<FleetState> {
|
|
|
181
219
|
}
|
|
182
220
|
|
|
183
221
|
const anyFailed = spec.workers.some((w) =>
|
|
184
|
-
["failed", "contract_failed"].includes(state.nodes[w.id]
|
|
222
|
+
["failed", "contract_failed"].includes(state.nodes[w.id]?.status ?? ""));
|
|
185
223
|
if (anyFailed) {
|
|
186
224
|
state = { ...state, status: "failed" };
|
|
187
225
|
await writeState(fleetRoot, state);
|