agent-lyceum 0.17.3 → 0.19.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/README.md +4 -3
- package/README.zh-TW.md +4 -3
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +39 -8
- package/dist/adapters/codex.d.ts +7 -0
- package/dist/adapters/codex.js +42 -1
- package/dist/adapters/types.d.ts +5 -0
- package/dist/call.d.ts +5 -0
- package/dist/call.js +35 -13
- package/dist/cli.js +17 -9
- package/dist/config.d.ts +36 -7
- package/dist/config.js +75 -8
- package/dist/demo.js +0 -1
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +3 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +25 -17
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -11
- package/dist/policy.d.ts +2 -6
- package/dist/policy.js +6 -10
- package/dist/process-runner.js +4 -2
- package/dist/project-lock.d.ts +10 -2
- package/dist/project-lock.js +23 -12
- package/dist/prompt.js +0 -3
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +11 -7
- package/dist/run-session.js +184 -113
- package/dist/run-store.d.ts +32 -7
- package/dist/run-store.js +23 -1
- package/dist/scaffold.js +0 -5
- package/dist/schema.d.ts +117 -22
- package/dist/schema.js +22 -7
- package/dist/solo.js +1 -2
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +41 -38
- package/dist/worktree.d.ts +19 -12
- package/dist/worktree.js +35 -74
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { absPath, homePaths, projectPaths, } from "./paths.js";
|
|
5
|
-
import { DISPATCHER_DEFAULTS, GlobalConfig, ProjectConfig, inferRuntime, } from "./schema.js";
|
|
5
|
+
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
|
|
6
6
|
export class ConfigError extends Error {
|
|
7
7
|
}
|
|
8
8
|
function readYaml(file, schema, label) {
|
|
@@ -58,7 +58,8 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
58
58
|
const paths = projectPaths(home, name);
|
|
59
59
|
const hp = homePaths(home);
|
|
60
60
|
const lead = p.team.lead;
|
|
61
|
-
const
|
|
61
|
+
const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
|
|
62
|
+
const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
|
|
62
63
|
const agents = {};
|
|
63
64
|
for (const agentName of Object.keys(p.agents)) {
|
|
64
65
|
const gl = resolveLayer(g.agents[agentName], home);
|
|
@@ -128,19 +129,32 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
128
129
|
break;
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
// Precedence: the agent's own chain (project > global), then dispatcher.fallback, then none. `[]` is a real value: no fallback.
|
|
133
|
+
let rawFallback = pick("fallback");
|
|
134
|
+
let fallbackShared = false;
|
|
135
|
+
if (rawFallback === undefined) {
|
|
136
|
+
fallbackShared = dispatcherFallback !== undefined;
|
|
137
|
+
if (dispatcherFallback !== undefined) {
|
|
138
|
+
rawFallback = dispatcherFallback;
|
|
139
|
+
sources.fallback = "project";
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
rawFallback = [];
|
|
143
|
+
sources.fallback = "default";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
|
|
131
147
|
const isLead = agentName === lead;
|
|
132
148
|
const resume = pick("resume");
|
|
133
149
|
const canMessage = pick("can_message");
|
|
134
150
|
const canEdit = pick("can_edit_agent_md");
|
|
135
151
|
const canAsk = pick("can_ask_user");
|
|
136
152
|
const allowWeb = pick("allow_web");
|
|
137
|
-
const owns = pick("owns");
|
|
138
153
|
for (const [k, v] of [
|
|
139
154
|
["resume", resume],
|
|
140
155
|
["can_message", canMessage],
|
|
141
156
|
["can_edit_agent_md", canEdit],
|
|
142
157
|
["can_ask_user", canAsk],
|
|
143
|
-
["owns", owns],
|
|
144
158
|
]) {
|
|
145
159
|
if (v === undefined)
|
|
146
160
|
sources[k] = "default";
|
|
@@ -157,7 +171,9 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
157
171
|
canEditAgentMd: canEdit ?? isLead,
|
|
158
172
|
canAskUser: canAsk ?? isLead,
|
|
159
173
|
allowWeb,
|
|
160
|
-
|
|
174
|
+
fallback,
|
|
175
|
+
fallbackShared,
|
|
176
|
+
fallbackInvalid,
|
|
161
177
|
sources,
|
|
162
178
|
};
|
|
163
179
|
}
|
|
@@ -169,7 +185,45 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
169
185
|
dispatcher,
|
|
170
186
|
agents,
|
|
171
187
|
paths,
|
|
188
|
+
ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Fill each entry's runtime: `inferRuntime(model)`, else the agent's own. Entries with nothing to go on are set aside, not guessed. */
|
|
192
|
+
function resolveFallback(raw, agentRuntime) {
|
|
193
|
+
const entries = [];
|
|
194
|
+
const invalid = [];
|
|
195
|
+
for (const [index, e] of raw.entries()) {
|
|
196
|
+
if (e.runtime === undefined && !e.model) {
|
|
197
|
+
invalid.push({ index, reason: "empty" });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const runtime = e.runtime ?? inferRuntime(e.model) ?? agentRuntime;
|
|
201
|
+
if (!runtime) {
|
|
202
|
+
invalid.push({ index, reason: "no-runtime" });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
entries.push({ runtime, model: e.model ?? "", ...(e.effort ? { effort: e.effort } : {}) });
|
|
206
|
+
}
|
|
207
|
+
return { entries, invalid };
|
|
208
|
+
}
|
|
209
|
+
/** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
|
|
210
|
+
function collectIgnored(globalFile, g, projectFile, p) {
|
|
211
|
+
const out = [];
|
|
212
|
+
const scan = (file, dispatcher, agents) => {
|
|
213
|
+
const keys = [];
|
|
214
|
+
for (const k of REMOVED_DISPATCHER_KEYS)
|
|
215
|
+
if (dispatcher?.[k] !== undefined)
|
|
216
|
+
keys.push(`dispatcher.${k}`);
|
|
217
|
+
for (const [name, a] of Object.entries(agents))
|
|
218
|
+
for (const k of REMOVED_AGENT_KEYS)
|
|
219
|
+
if (a[k] !== undefined)
|
|
220
|
+
keys.push(`agents.${name}.${k}`);
|
|
221
|
+
if (keys.length)
|
|
222
|
+
out.push({ file, keys });
|
|
172
223
|
};
|
|
224
|
+
scan(globalFile, undefined, g.agents);
|
|
225
|
+
scan(projectFile, p.dispatcher, p.agents);
|
|
226
|
+
return out;
|
|
173
227
|
}
|
|
174
228
|
function stripUndef(o) {
|
|
175
229
|
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
@@ -205,7 +259,7 @@ export function findProjectForCwd(home, cwd) {
|
|
|
205
259
|
}
|
|
206
260
|
return best?.name;
|
|
207
261
|
}
|
|
208
|
-
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "
|
|
262
|
+
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "fallback"];
|
|
209
263
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
210
264
|
export function resolveProjectWithSources(home, name) {
|
|
211
265
|
const project = resolveProject(home, name);
|
|
@@ -216,7 +270,10 @@ export function resolveProjectWithSources(home, name) {
|
|
|
216
270
|
dir: { file: projectFile, key: "dir" },
|
|
217
271
|
"team.lead": { file: projectFile, key: "team.lead" },
|
|
218
272
|
};
|
|
273
|
+
const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
|
|
219
274
|
for (const k of Object.keys(DISPATCHER_DEFAULTS)) {
|
|
275
|
+
if (k === "fallback_cooldown_sec" && !anyFallback)
|
|
276
|
+
continue;
|
|
220
277
|
sources[`dispatcher.${k}`] = raw.dispatcher[k] !== undefined ? { file: projectFile, key: `dispatcher.${k}` } : { default: true };
|
|
221
278
|
}
|
|
222
279
|
for (const a of Object.values(project.agents)) {
|
|
@@ -232,6 +289,8 @@ export function resolveProjectWithSources(home, name) {
|
|
|
232
289
|
const src = a.sources[f];
|
|
233
290
|
if (src === undefined)
|
|
234
291
|
continue; // not set anywhere (e.g. no effort)
|
|
292
|
+
if (f === "fallback" && a.fallback.length === 0)
|
|
293
|
+
continue;
|
|
235
294
|
if (src === "model")
|
|
236
295
|
sources[`agents.${a.name}.${f}`] = (() => {
|
|
237
296
|
const m = at(a.sources.model, "model");
|
|
@@ -240,6 +299,9 @@ export function resolveProjectWithSources(home, name) {
|
|
|
240
299
|
// A project-level AGENT.md found by convention is not a setting in project.yaml.
|
|
241
300
|
else if (f === "agent_md" && src === "project" && raw.agents[a.name]?.agent_md === undefined)
|
|
242
301
|
sources[`agents.${a.name}.${f}`] = { default: true };
|
|
302
|
+
// A chain inherited from dispatcher.fallback lives under that key, not under the agent.
|
|
303
|
+
else if (f === "fallback" && src === "project" && raw.agents[a.name]?.fallback === undefined)
|
|
304
|
+
sources[`agents.${a.name}.${f}`] = { file: projectFile, key: "dispatcher.fallback" };
|
|
243
305
|
else
|
|
244
306
|
sources[`agents.${a.name}.${f}`] = at(src, f);
|
|
245
307
|
}
|
|
@@ -249,8 +311,12 @@ export function resolveProjectWithSources(home, name) {
|
|
|
249
311
|
/** Every effective setting under the same keys as `resolveProjectWithSources` reports sources for. */
|
|
250
312
|
export function flattenResolved(project) {
|
|
251
313
|
const v = { dir: project.dir, "team.lead": project.lead };
|
|
252
|
-
|
|
314
|
+
const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
|
|
315
|
+
for (const [k, val] of Object.entries(project.dispatcher)) {
|
|
316
|
+
if (k === "fallback_cooldown_sec" && !anyFallback)
|
|
317
|
+
continue; // keep `doctor --resolved` unchanged without fallback
|
|
253
318
|
v[`dispatcher.${k}`] = val;
|
|
319
|
+
}
|
|
254
320
|
for (const a of Object.values(project.agents)) {
|
|
255
321
|
const p = `agents.${a.name}`;
|
|
256
322
|
v[`${p}.runtime`] = a.runtime;
|
|
@@ -264,7 +330,8 @@ export function flattenResolved(project) {
|
|
|
264
330
|
v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
|
|
265
331
|
v[`${p}.can_ask_user`] = a.canAskUser;
|
|
266
332
|
v[`${p}.allow_web`] = a.allowWeb;
|
|
267
|
-
|
|
333
|
+
if (a.fallback.length)
|
|
334
|
+
v[`${p}.fallback`] = a.fallback;
|
|
268
335
|
}
|
|
269
336
|
return v;
|
|
270
337
|
}
|
package/dist/demo.js
CHANGED
|
@@ -82,7 +82,6 @@ export function prepareDemo(dir, runtime) {
|
|
|
82
82
|
// One runtime for the whole team, and the roles scoped to the sample repo.
|
|
83
83
|
for (const agent of ["lead", "fe-member", "qa-member"])
|
|
84
84
|
yaml = yaml.replace(` ${agent}:\n`, ` ${agent}:\n runtime: ${runtime}\n`);
|
|
85
|
-
yaml = yaml.replace('# owns: ["src/web/**"]', 'owns: ["src/**"]').replace('# owns: ["tests/**"]', 'owns: ["test/**"]');
|
|
86
85
|
fs.writeFileSync(cfg, yaml);
|
|
87
86
|
return { dir: root, home, repo };
|
|
88
87
|
}
|
package/dist/dispatcher.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ import { newRunId } from "./run-store.js";
|
|
|
3
3
|
export { newRunId };
|
|
4
4
|
export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
|
|
5
5
|
export { MAX_DONE_REJECTIONS, RESULT_FILE, type RunOptions, type RunSummary } from "./run-session.js";
|
|
6
|
-
|
|
6
|
+
/** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
|
|
7
|
+
export declare function runTeam(opts: Omit<RunOptions, "plan"> & Partial<Pick<RunOptions, "plan">>): Promise<RunSummary>;
|
package/dist/dispatcher.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { RunSession } from "./run-session.js";
|
|
2
2
|
import { newRunId } from "./run-store.js";
|
|
3
|
+
import { planWorkspace } from "./worktree.js";
|
|
3
4
|
export { newRunId };
|
|
4
5
|
export { MAX_DONE_REJECTIONS, RESULT_FILE } from "./run-session.js";
|
|
6
|
+
/** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
|
|
5
7
|
export async function runTeam(opts) {
|
|
6
|
-
return new RunSession(opts).run();
|
|
8
|
+
return new RunSession({ ...opts, plan: opts.plan ?? planWorkspace(opts.project, opts.resume) }).run();
|
|
7
9
|
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export interface DoctorReport {
|
|
|
35
35
|
checks: DoctorCheck[];
|
|
36
36
|
runtimes: RuntimeCapabilities[];
|
|
37
37
|
}
|
|
38
|
-
export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<RuntimeCapabilities[]>;
|
|
38
|
+
export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions, withFallback?: boolean): Promise<RuntimeCapabilities[]>;
|
|
39
39
|
/** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
|
|
40
40
|
export declare function preflightRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<{
|
|
41
41
|
errors: string[];
|
package/dist/doctor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { tidyAdvice } from "./memory-tidy.js";
|
|
|
6
6
|
import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
|
|
7
7
|
import { runInvocation } from "./process-runner.js";
|
|
8
8
|
import { validateProject } from "./validate.js";
|
|
9
|
-
import { dirtyPaths, git,
|
|
9
|
+
import { dirtyPaths, git, isolationBlocker } from "./worktree.js";
|
|
10
10
|
const BINARY = { "claude-code": "claude", codex: "codex" };
|
|
11
11
|
/** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
|
|
12
12
|
async function query(cmd, args, opts) {
|
|
@@ -76,7 +76,13 @@ function judgeRuntimes(project, caps) {
|
|
|
76
76
|
const cmd = BINARY[c.runtime];
|
|
77
77
|
const users = Object.values(project.agents).filter((a) => a.runtime === c.runtime);
|
|
78
78
|
if (c.binary === "missing") {
|
|
79
|
-
|
|
79
|
+
if (!users.length) {
|
|
80
|
+
// Only a fallback chain uses it: a backup that is not installed must not stop a run that works until the primary fails.
|
|
81
|
+
const backups = Object.values(project.agents).filter((a) => a.fallback.some((f) => f.runtime === c.runtime));
|
|
82
|
+
checks.push({ level: "warn", subject: c.runtime, message: `${cmd} not found on PATH (it is a fallback for ${backups.map((a) => a.name).join(", ")}).` });
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
|
|
80
86
|
continue;
|
|
81
87
|
}
|
|
82
88
|
checks.push({ level: "ok", subject: c.runtime, message: `${cmd} ${c.version ?? "(version unknown)"}` });
|
|
@@ -96,9 +102,12 @@ function judgeRuntimes(project, caps) {
|
|
|
96
102
|
}
|
|
97
103
|
return checks;
|
|
98
104
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
/** Runtimes of the agents themselves; with `withFallback`, also those that only a fallback chain uses. */
|
|
106
|
+
const runtimesUsed = (project, withFallback) => [
|
|
107
|
+
...new Set(Object.values(project.agents).flatMap((a) => [a.runtime, ...(withFallback ? a.fallback.map((f) => f.runtime) : [])]).filter((r) => !!r)),
|
|
108
|
+
];
|
|
109
|
+
export async function probeProjectRuntimes(project, opts = {}, withFallback = false) {
|
|
110
|
+
return Promise.all(runtimesUsed(project, withFallback).map((r) => probeRuntime(r, opts)));
|
|
102
111
|
}
|
|
103
112
|
/** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
|
|
104
113
|
export async function preflightRuntimes(project, opts = {}) {
|
|
@@ -116,20 +125,19 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
116
125
|
checks.push({ level: i.level === "error" ? "error" : "warn", subject: "config", message: i.message });
|
|
117
126
|
if (!v.issues.length)
|
|
118
127
|
checks.push({ level: "ok", subject: "config", message: "Configuration is valid." });
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
const blocker = fs.existsSync(project.dir) ? isolationBlocker(project.dir) : undefined;
|
|
129
|
+
if (fs.existsSync(project.dir)) {
|
|
130
|
+
if (blocker) {
|
|
131
|
+
checks.push({ level: "info", subject: "git", message: `Runs work directly in ${project.dir}, one agent at a time (${blocker}).` });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
121
134
|
const dirty = dirtyPaths(project.dir);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
else if (dirty.length) {
|
|
126
|
-
checks.push({ level: "warn", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: a parallel run would refuse to start until they are committed or stashed.` });
|
|
127
|
-
}
|
|
128
|
-
else
|
|
129
|
-
checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
|
|
135
|
+
checks.push(dirty.length
|
|
136
|
+
? { level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: runs start from HEAD and do not see them.` }
|
|
137
|
+
: { level: "ok", subject: "git", message: "git repo with a commit: runs and agents work in their own worktrees." });
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
|
-
if (
|
|
140
|
+
if (fs.existsSync(project.dir) && !blocker) {
|
|
133
141
|
const missing = [];
|
|
134
142
|
if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
|
|
135
143
|
missing.push("`gh` is not on PATH");
|
|
@@ -161,7 +169,7 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
161
169
|
}
|
|
162
170
|
for (const a of tidyAdvice(project))
|
|
163
171
|
checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
|
|
164
|
-
const runtimes = await probeProjectRuntimes(project, opts);
|
|
172
|
+
const runtimes = await probeProjectRuntimes(project, opts, true);
|
|
165
173
|
checks.push(...judgeRuntimes(project, runtimes));
|
|
166
174
|
checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
|
|
167
175
|
return { project: project.name, ok: !checks.some((c) => c.level === "error"), checks, runtimes };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ResolvedAgent } from "./config.js";
|
|
2
|
+
import type { Effort, ErrorKind, Runtime } from "./schema.js";
|
|
3
|
+
/** One model a wake may run on: the agent's own (`primary`) or a link of its fallback chain. */
|
|
4
|
+
export interface Candidate {
|
|
5
|
+
runtime: Runtime;
|
|
6
|
+
/** Unset = the runtime's own default model. */
|
|
7
|
+
model?: string;
|
|
8
|
+
effort?: Effort;
|
|
9
|
+
primary: boolean;
|
|
10
|
+
/** `<runtime>/<model>`; the cooldown and session key. */
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
|
|
15
|
+
* earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
|
|
16
|
+
*/
|
|
17
|
+
export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
|
|
18
|
+
/**
|
|
19
|
+
* The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
|
|
20
|
+
* and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
|
|
21
|
+
* tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function agentForCandidate(agent: ResolvedAgent, c: Candidate): ResolvedAgent;
|
|
24
|
+
/** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
|
|
25
|
+
export declare class Cooldowns {
|
|
26
|
+
private table;
|
|
27
|
+
/** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
|
|
28
|
+
mark(key: string, kind: ErrorKind, now: number, sec: number): void;
|
|
29
|
+
/** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
|
|
30
|
+
active(key: string, now: number): number;
|
|
31
|
+
toJSON(): Record<string, {
|
|
32
|
+
until: string;
|
|
33
|
+
kind: ErrorKind;
|
|
34
|
+
}>;
|
|
35
|
+
/** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
|
|
36
|
+
static from(json?: Record<string, {
|
|
37
|
+
until: string;
|
|
38
|
+
kind: ErrorKind;
|
|
39
|
+
}>): Cooldowns;
|
|
40
|
+
}
|
|
41
|
+
/** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
|
|
42
|
+
export declare function planAttempts(agent: ResolvedAgent, cooldowns: Cooldowns, now: number): Candidate[];
|
|
43
|
+
export type Step = "retry-same" | "next-candidate" | "stop";
|
|
44
|
+
/**
|
|
45
|
+
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
46
|
+
* candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
|
|
47
|
+
* keeps the old loop: every kind is retried `retry` times, then the wake fails.
|
|
48
|
+
*/
|
|
49
|
+
export declare function nextStep(kind: ErrorKind | undefined, attemptsOnCurrent: number, retry: number, hasNext: boolean, legacy: boolean): Step;
|
|
50
|
+
/** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
|
|
51
|
+
export declare function backoffMs(n: number): number;
|
|
52
|
+
/** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
|
|
53
|
+
export declare function defaultSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
54
|
+
/** What the driver needs to know about a call's result. */
|
|
55
|
+
export interface AttemptResult {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
kind?: ErrorKind;
|
|
58
|
+
cancelled?: boolean;
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface AttemptCtx<R> {
|
|
62
|
+
/** Calls made so far in this wake, across candidates (0 = the first). */
|
|
63
|
+
attempt: number;
|
|
64
|
+
/** Calls made so far on this candidate; restarts at 0 on a switch. */
|
|
65
|
+
candidateAttempt: number;
|
|
66
|
+
/** The previous call's failed result, for a retry prompt. */
|
|
67
|
+
previous?: R;
|
|
68
|
+
/** Set on the first call after switching: the candidate given up on. */
|
|
69
|
+
switchedFrom?: Candidate;
|
|
70
|
+
}
|
|
71
|
+
/** A failed call, in order, for `failure.tried`. */
|
|
72
|
+
export interface Tried {
|
|
73
|
+
runtime: Runtime;
|
|
74
|
+
model?: string;
|
|
75
|
+
kind?: ErrorKind;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface RunWithFallbackOptions<R extends AttemptResult> {
|
|
79
|
+
/** From `planAttempts`; tried in order. */
|
|
80
|
+
candidates: Candidate[];
|
|
81
|
+
/** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
|
|
82
|
+
legacy: boolean;
|
|
83
|
+
/** Retries per candidate (`dispatcher.retry`). */
|
|
84
|
+
retry: number;
|
|
85
|
+
/** Marked on `rate_limit`/`quota` failures. */
|
|
86
|
+
cooldowns: Cooldowns;
|
|
87
|
+
cooldownSec: number;
|
|
88
|
+
invoke(candidate: Candidate, ctx: AttemptCtx<R>): Promise<R>;
|
|
89
|
+
/** Runs before each call; "stop" ends the loop without calling (e.g. the round limit). */
|
|
90
|
+
beforeAttempt?(candidate: Candidate, ctx: AttemptCtx<R>): "stop" | undefined;
|
|
91
|
+
/** Runs after each call, awaited before the next decision. */
|
|
92
|
+
afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
|
|
93
|
+
onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
|
|
94
|
+
sleep?(ms: number, signal?: AbortSignal): Promise<void>;
|
|
95
|
+
now?(): number;
|
|
96
|
+
signal?: AbortSignal;
|
|
97
|
+
}
|
|
98
|
+
export interface FallbackOutcome<R> {
|
|
99
|
+
/** The last call's result; undefined only when stopped before the first call. */
|
|
100
|
+
result: R | undefined;
|
|
101
|
+
tried: Tried[];
|
|
102
|
+
/** The candidate of the last call (or the first one, when none was made). */
|
|
103
|
+
used: Candidate;
|
|
104
|
+
/** Why the loop ended without a final verdict from a call: `beforeAttempt` said stop, or the signal aborted during a backoff. */
|
|
105
|
+
stopped?: "beforeAttempt" | "aborted";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
|
|
109
|
+
* retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runWithFallback<R extends AttemptResult>(o: RunWithFallbackOptions<R>): Promise<FallbackOutcome<R>>;
|
package/dist/fallback.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** `<runtime>/<model>`, the model trimmed and lowercased (no model = ""), so entries naming the same model share one key. */
|
|
2
|
+
const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase()}`;
|
|
3
|
+
/**
|
|
4
|
+
* The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
|
|
5
|
+
* earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
|
|
6
|
+
*/
|
|
7
|
+
export function candidatesFor(agent) {
|
|
8
|
+
// `validate` refuses an agent without a runtime before anything is woken.
|
|
9
|
+
const runtime = agent.runtime;
|
|
10
|
+
const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
|
|
11
|
+
const out = [primary];
|
|
12
|
+
const seen = new Set([primary.key]);
|
|
13
|
+
for (const f of agent.fallback) {
|
|
14
|
+
const model = f.model || undefined;
|
|
15
|
+
const key = keyOf(f.runtime, model);
|
|
16
|
+
if (seen.has(key))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
out.push({ runtime: f.runtime, model, effort: f.effort, primary: false, key });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
|
|
25
|
+
* and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
|
|
26
|
+
* tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
|
|
27
|
+
*/
|
|
28
|
+
export function agentForCandidate(agent, c) {
|
|
29
|
+
if (c.primary)
|
|
30
|
+
return agent;
|
|
31
|
+
const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
|
|
32
|
+
return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
|
|
33
|
+
}
|
|
34
|
+
/** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
|
|
35
|
+
export class Cooldowns {
|
|
36
|
+
table = new Map();
|
|
37
|
+
/** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
|
|
38
|
+
mark(key, kind, now, sec) {
|
|
39
|
+
if (sec <= 0)
|
|
40
|
+
return;
|
|
41
|
+
const until = now + sec * 1000;
|
|
42
|
+
if ((this.table.get(key)?.until ?? 0) >= until)
|
|
43
|
+
return;
|
|
44
|
+
this.table.set(key, { until, kind });
|
|
45
|
+
}
|
|
46
|
+
/** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
|
|
47
|
+
active(key, now) {
|
|
48
|
+
const until = this.table.get(key)?.until ?? 0;
|
|
49
|
+
return Math.max(0, until - now);
|
|
50
|
+
}
|
|
51
|
+
toJSON() {
|
|
52
|
+
return Object.fromEntries([...this.table].map(([k, v]) => [k, { until: new Date(v.until).toISOString(), kind: v.kind }]));
|
|
53
|
+
}
|
|
54
|
+
/** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
|
|
55
|
+
static from(json) {
|
|
56
|
+
const c = new Cooldowns();
|
|
57
|
+
for (const [k, v] of Object.entries(json ?? {})) {
|
|
58
|
+
const until = Date.parse(v.until);
|
|
59
|
+
if (Number.isFinite(until))
|
|
60
|
+
c.table.set(k, { until, kind: v.kind });
|
|
61
|
+
}
|
|
62
|
+
return c;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
|
|
66
|
+
export function planAttempts(agent, cooldowns, now) {
|
|
67
|
+
const all = candidatesFor(agent);
|
|
68
|
+
const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
|
|
69
|
+
return warm.length ? warm : all;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
73
|
+
* candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
|
|
74
|
+
* keeps the old loop: every kind is retried `retry` times, then the wake fails.
|
|
75
|
+
*/
|
|
76
|
+
export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
|
|
77
|
+
const retriesLeft = attemptsOnCurrent <= retry;
|
|
78
|
+
if (legacy)
|
|
79
|
+
return retriesLeft ? "retry-same" : "stop";
|
|
80
|
+
switch (kind) {
|
|
81
|
+
case "rate_limit":
|
|
82
|
+
case "quota":
|
|
83
|
+
return hasNext ? "next-candidate" : "stop";
|
|
84
|
+
case "timeout":
|
|
85
|
+
case "network":
|
|
86
|
+
case "crash":
|
|
87
|
+
return retriesLeft ? "retry-same" : hasNext ? "next-candidate" : "stop";
|
|
88
|
+
case "auth":
|
|
89
|
+
return "stop";
|
|
90
|
+
default:
|
|
91
|
+
// `unknown` and unclassified failures: same model only, as before fallback existed.
|
|
92
|
+
return retriesLeft ? "retry-same" : "stop";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
|
|
96
|
+
export function backoffMs(n) {
|
|
97
|
+
return Math.min(5 * 3 ** (n - 1), 60) * 1000;
|
|
98
|
+
}
|
|
99
|
+
/** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
|
|
100
|
+
export function defaultSleep(ms, signal) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
if (signal?.aborted)
|
|
103
|
+
return resolve();
|
|
104
|
+
const done = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
signal?.removeEventListener("abort", done);
|
|
107
|
+
resolve();
|
|
108
|
+
};
|
|
109
|
+
const timer = setTimeout(done, ms);
|
|
110
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
|
|
115
|
+
* retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
|
|
116
|
+
*/
|
|
117
|
+
export async function runWithFallback(o) {
|
|
118
|
+
const first = o.candidates[0];
|
|
119
|
+
if (!first)
|
|
120
|
+
throw new Error("runWithFallback needs at least one candidate");
|
|
121
|
+
const sleep = o.sleep ?? defaultSleep;
|
|
122
|
+
const now = o.now ?? Date.now;
|
|
123
|
+
const tried = [];
|
|
124
|
+
let index = 0;
|
|
125
|
+
let used = first;
|
|
126
|
+
let ctx = { attempt: 0, candidateAttempt: 0 };
|
|
127
|
+
let result;
|
|
128
|
+
for (;;) {
|
|
129
|
+
if (o.beforeAttempt?.(used, ctx) === "stop")
|
|
130
|
+
return { result, tried, used, stopped: "beforeAttempt" };
|
|
131
|
+
result = await o.invoke(used, ctx);
|
|
132
|
+
await o.afterAttempt?.(used, result, ctx);
|
|
133
|
+
if (result.ok || result.cancelled)
|
|
134
|
+
return { result, tried, used };
|
|
135
|
+
tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
|
|
136
|
+
if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
|
|
137
|
+
o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
|
|
138
|
+
const next = o.candidates[index + 1];
|
|
139
|
+
const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
|
|
140
|
+
if (step === "stop")
|
|
141
|
+
return { result, tried, used };
|
|
142
|
+
if (step === "retry-same") {
|
|
143
|
+
if (!o.legacy) {
|
|
144
|
+
await sleep(backoffMs(ctx.candidateAttempt + 1), o.signal);
|
|
145
|
+
if (o.signal?.aborted)
|
|
146
|
+
return { result, tried, used, stopped: "aborted" };
|
|
147
|
+
}
|
|
148
|
+
ctx = { attempt: ctx.attempt + 1, candidateAttempt: ctx.candidateAttempt + 1, previous: result };
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const to = next;
|
|
152
|
+
o.onSwitch?.(used, to, result.kind, result);
|
|
153
|
+
ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
|
|
154
|
+
index++;
|
|
155
|
+
used = to;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -26,5 +26,3 @@ export interface ProjectPaths {
|
|
|
26
26
|
export declare function projectPaths(home: string, project: string): ProjectPaths;
|
|
27
27
|
/** True when `child` is `parent` or inside it. */
|
|
28
28
|
export declare function isInside(parent: string, child: string): boolean;
|
|
29
|
-
/** Directory part of a glob that precedes the first wildcard segment. */
|
|
30
|
-
export declare function globStaticPrefix(glob: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -49,14 +49,3 @@ export function isInside(parent, child) {
|
|
|
49
49
|
const rel = path.relative(parent, child);
|
|
50
50
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
51
51
|
}
|
|
52
|
-
/** Directory part of a glob that precedes the first wildcard segment. */
|
|
53
|
-
export function globStaticPrefix(glob) {
|
|
54
|
-
const segs = glob.split("/");
|
|
55
|
-
const out = [];
|
|
56
|
-
for (const s of segs) {
|
|
57
|
-
if (/[*?[\]{}]/.test(s))
|
|
58
|
-
break;
|
|
59
|
-
out.push(s);
|
|
60
|
-
}
|
|
61
|
-
return out.join("/");
|
|
62
|
-
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -8,7 +8,6 @@ export interface AgentEnforcement {
|
|
|
8
8
|
otherContext: Level;
|
|
9
9
|
shared: Level;
|
|
10
10
|
repoInstructions: Level;
|
|
11
|
-
owns: Level;
|
|
12
11
|
/** Absolute paths outside the repo this agent may write. */
|
|
13
12
|
writablePaths: string[];
|
|
14
13
|
notes: string[];
|
|
@@ -22,9 +21,6 @@ export interface WritePolicy {
|
|
|
22
21
|
allowFiles: string[];
|
|
23
22
|
/** Files/dirs that must stay unwritable (checked even inside the repo). */
|
|
24
23
|
deny: string[];
|
|
25
|
-
/** Repo-relative globs the agent owns (only enforced when max_parallel > 1). */
|
|
26
|
-
owns: string[];
|
|
27
|
-
restrictRepoToOwns: boolean;
|
|
28
24
|
}
|
|
29
25
|
export declare function commonFile(project: ResolvedProject): string;
|
|
30
26
|
export declare function outboxDir(project: ResolvedProject, agent: string): string;
|
|
@@ -41,8 +37,8 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
|
|
|
41
37
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
42
38
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
43
39
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
|
40
|
+
/** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
|
|
41
|
+
export declare function setOsSandboxForTest(available: boolean | undefined): void;
|
|
44
42
|
export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
|
|
45
|
-
/** Static directory prefixes (absolute) of an agent's `owns` globs. */
|
|
46
|
-
export declare function ownsDirs(project: ResolvedProject, agent: ResolvedAgent): string[];
|
|
47
43
|
/** Can `agent` legitimately change this protected file? */
|
|
48
44
|
export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
|