agent-lyceum 0.18.0 → 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 +1 -0
- package/README.zh-TW.md +1 -0
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +38 -1
- 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 +34 -11
- package/dist/config.d.ts +26 -1
- package/dist/config.js +53 -3
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +14 -5
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +4 -0
- package/dist/process-runner.js +4 -2
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +8 -6
- package/dist/run-session.js +164 -83
- package/dist/run-store.d.ts +31 -6
- package/dist/run-store.js +23 -1
- package/dist/schema.d.ts +105 -0
- package/dist/schema.js +14 -0
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
|
|
|
11
11
|
- **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
|
|
12
12
|
- **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
|
|
13
13
|
- **Mix runtimes.** Claude Code and Codex agents work in the same team.
|
|
14
|
+
- **Backup models.** If a model is rate-limited, out of quota or unreachable, a run can switch to a backup model or runtime instead of stopping. See [Configuration](docs/configuration.md#fallback-models).
|
|
14
15
|
- **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
|
|
15
16
|
|
|
16
17
|
**Not a fit** for small, one-shot edits: a single Claude Code or Codex session is faster and cheaper. Use a team when the task is big enough to split, review or run in parallel.
|
package/README.zh-TW.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
|
|
12
12
|
- **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
|
|
13
13
|
- **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
|
|
14
|
+
- **備用模型。** 模型被限流、額度用完或連不上時,run 可以改用備用的模型或 runtime 繼續,而不是直接停下。見[設定](docs/configuration.zh-TW.md#備援模型)。
|
|
14
15
|
- **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
|
|
15
16
|
|
|
16
17
|
**不適合**小而單次的修改:單一 Claude Code 或 Codex session 更快更省。任務大到值得拆分、review 或平行執行時,再用團隊。
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
|
+
import type { ErrorKind } from "../schema.js";
|
|
2
3
|
import type { Invocation, WakeInput } from "./types.js";
|
|
3
4
|
export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent): Record<string, unknown>;
|
|
4
5
|
export declare function buildClaudeInvocation(input: WakeInput): Invocation;
|
|
6
|
+
/**
|
|
7
|
+
* The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
|
|
8
|
+
* the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
|
|
9
|
+
* output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function classifyClaudeError(out: {
|
|
12
|
+
code: number | null;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
result?: string;
|
|
16
|
+
}): ErrorKind;
|
|
5
17
|
interface ClaudeResult {
|
|
6
18
|
result?: string;
|
|
7
19
|
session_id?: string;
|
package/dist/adapters/claude.js
CHANGED
|
@@ -88,9 +88,45 @@ export function buildClaudeInvocation(input) {
|
|
|
88
88
|
stream: () => claudeStream(),
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Ordered, first match wins: put the more specific kind before the broader one (a usage limit is `quota`, not `rate_limit`).
|
|
93
|
+
* Samples: observed = captured from a real run, documented = from the CLI/API docs, not yet seen in a log.
|
|
94
|
+
*/
|
|
95
|
+
const CLAUDE_RULES = [
|
|
96
|
+
// observed: `Invalid API key · Fix external API key` with api_error_status 401; documented: `Not logged in · Please run /login`
|
|
97
|
+
{ kind: "auth", re: /\bHTTP 401\b|invalid api key|not logged in|please run \/login/i },
|
|
98
|
+
// documented: `5-hour limit reached ∙ resets 3pm`, API 400 `Credit balance is too low`
|
|
99
|
+
{ kind: "quota", re: /\b(5-hour|weekly|usage) limit reached\b|credit balance is too low/i },
|
|
100
|
+
// documented: API 429 `rate_limit_error`, 529 `overloaded_error`
|
|
101
|
+
{ kind: "rate_limit", re: /\bHTTP (429|529)\b|rate_limit_error|rate limit (reached|exceeded)|overloaded_error|^api error: overloaded/im },
|
|
102
|
+
// observed: `API Error: Connection refused — ... (ECONNREFUSED)`; documented: the other Node error codes
|
|
103
|
+
{ kind: "network", re: /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|socket hang up/i },
|
|
104
|
+
// documented: a native crash prints one of these (a signal kill has no exit code, see `isSignalKill`)
|
|
105
|
+
{ kind: "crash", re: /segmentation fault|heap out of memory/i },
|
|
106
|
+
];
|
|
107
|
+
/** A process that ended without an exit code was killed by a signal that was not ours (timeouts and cancels never get here). */
|
|
108
|
+
const isSignalKill = (code) => code === null;
|
|
109
|
+
function classifyWith(rules, code, text) {
|
|
110
|
+
const hit = rules.find((r) => r.re.test(text));
|
|
111
|
+
if (hit)
|
|
112
|
+
return hit.kind;
|
|
113
|
+
return isSignalKill(code) ? "crash" : "unknown"; // never default to crash: a wrong guess would switch models for nothing
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
|
|
117
|
+
* the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
|
|
118
|
+
* output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
|
|
119
|
+
*/
|
|
120
|
+
export function classifyClaudeError(out) {
|
|
121
|
+
const status = /"api_error_status":\s*(\d{3})/.exec(out.stdout)?.[1];
|
|
122
|
+
const j = extractClaudeResult(out.stdout);
|
|
123
|
+
const structured = /^[[{]/.test(out.stdout.trimStart());
|
|
124
|
+
const message = out.result ?? (j ? (j.is_error ? String(j.result ?? "") : "") : structured ? "" : out.stdout);
|
|
125
|
+
return classifyWith(CLAUDE_RULES, out.code, [message, out.stderr, status ? `HTTP ${status}` : ""].join("\n"));
|
|
126
|
+
}
|
|
91
127
|
function interpretClaude(j, stdout, stderr, code) {
|
|
92
128
|
if (!j)
|
|
93
|
-
return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500) };
|
|
129
|
+
return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500), kind: classifyClaudeError({ code, stdout, stderr }) };
|
|
94
130
|
const ok = code === 0 && !j.is_error;
|
|
95
131
|
return {
|
|
96
132
|
ok,
|
|
@@ -98,6 +134,7 @@ function interpretClaude(j, stdout, stderr, code) {
|
|
|
98
134
|
sessionId: j.session_id,
|
|
99
135
|
outputTokens: j.usage?.output_tokens,
|
|
100
136
|
error: ok ? undefined : String(j.result ?? stderr).slice(0, 500),
|
|
137
|
+
kind: ok ? undefined : classifyClaudeError({ code, stdout, stderr, result: j.is_error && j.result !== undefined ? String(j.result) : undefined }),
|
|
101
138
|
};
|
|
102
139
|
}
|
|
103
140
|
/** Reads `--output-format json` output line by line: only JSON lines are kept, and only the last few, so a flood of other output costs nothing. */
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import type { ErrorKind } from "../schema.js";
|
|
1
2
|
import type { Invocation, WakeInput } from "./types.js";
|
|
2
3
|
export declare function codexWritableRoots(input: WakeInput): string[];
|
|
3
4
|
/** Sum `output_tokens` over `turn.completed` events in `codex exec --json` output. */
|
|
4
5
|
export declare function codexOutputTokens(jsonl: string): number | undefined;
|
|
5
6
|
export declare function buildCodexInvocation(input: WakeInput): Invocation;
|
|
7
|
+
export declare function classifyCodexError(out: {
|
|
8
|
+
code: number | null;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
result?: string;
|
|
12
|
+
}): ErrorKind;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -100,6 +100,46 @@ export function buildCodexInvocation(input) {
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Ordered, first match wins: put the more specific kind before the broader one (an exhausted quota also arrives as a 429).
|
|
105
|
+
* Samples: observed = captured from a real `codex exec`, documented = from the CLI/API docs, not yet seen in a log.
|
|
106
|
+
*/
|
|
107
|
+
const CODEX_RULES = [
|
|
108
|
+
// documented: `You exceeded your current quota` / `insufficient_quota`, ChatGPT plans: `You've hit your usage limit`
|
|
109
|
+
{ kind: "quota", re: /insufficient_quota|exceeded your current quota|hit your usage limit/i },
|
|
110
|
+
// documented: `exceeded retry limit, last status: 429 Too Many Requests`, `rate_limit_exceeded`
|
|
111
|
+
{ kind: "rate_limit", re: /\bstatus:? 429\b|\b429 too many requests|rate_limit_exceeded|rate limit reached/i },
|
|
112
|
+
// observed: `unexpected status 401 Unauthorized: Incorrect API key provided ... invalid_api_key`; documented: `Not logged in`
|
|
113
|
+
{ kind: "auth", re: /\bstatus:? 401\b|\b401 unauthorized|invalid_api_key|incorrect api key|not logged in|codex login/i },
|
|
114
|
+
// `Reconnecting... waiting for network (Connection failed: error sending request)` is observed as a retry event only (a real codex with a dead
|
|
115
|
+
// network never ends by itself: the runner times it out); a final-failure sample is documented-not-observed, as are DNS / Node error codes
|
|
116
|
+
{ kind: "network", re: /waiting for network|error sending request|dns error|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT/i },
|
|
117
|
+
{ kind: "crash", re: /segmentation fault|panicked at/i },
|
|
118
|
+
];
|
|
119
|
+
/** The message of the last `turn.failed` event: the final verdict, as opposed to the reconnect noise before it. */
|
|
120
|
+
function failureMessage(stdout) {
|
|
121
|
+
for (const line of stdout.split("\n").reverse()) {
|
|
122
|
+
if (!line.includes('"turn.failed"'))
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const m = JSON.parse(line).error?.message;
|
|
126
|
+
if (typeof m === "string")
|
|
127
|
+
return m;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* cut off by the tail window: not JSON */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
export function classifyCodexError(out) {
|
|
136
|
+
// Only error channels are read: the final turn.failed message, else stderr. stdout also carries command output and the agent's own words.
|
|
137
|
+
const text = out.result ?? failureMessage(out.stdout) ?? out.stderr;
|
|
138
|
+
const hit = CODEX_RULES.find((r) => r.re.test(text));
|
|
139
|
+
if (hit)
|
|
140
|
+
return hit.kind;
|
|
141
|
+
return out.code === null ? "crash" : "unknown"; // killed by a signal; otherwise never guess crash
|
|
142
|
+
}
|
|
103
143
|
function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
|
|
104
144
|
let text = "";
|
|
105
145
|
try {
|
|
@@ -109,5 +149,6 @@ function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
|
|
|
109
149
|
/* no last message */
|
|
110
150
|
}
|
|
111
151
|
const ok = code === 0;
|
|
112
|
-
|
|
152
|
+
// `error` is the last 500 chars of stderr||stdout; `kind` comes from the classifier's own, narrower source preference
|
|
153
|
+
return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500), kind: ok ? undefined : classifyCodexError({ code, stdout, stderr }) };
|
|
113
154
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ErrorKind } from "../schema.js";
|
|
1
2
|
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
3
|
import type { AgentWorkspace } from "../worktree.js";
|
|
3
4
|
export interface WakeInput {
|
|
@@ -26,6 +27,8 @@ export interface WakeResult {
|
|
|
26
27
|
error?: string;
|
|
27
28
|
/** Stopped because the run was cancelled, not because it failed. */
|
|
28
29
|
cancelled?: boolean;
|
|
30
|
+
/** Why it failed, for the fallback decision. Unset on success and on cancellation. */
|
|
31
|
+
kind?: ErrorKind;
|
|
29
32
|
}
|
|
30
33
|
export type Parsed = {
|
|
31
34
|
ok: boolean;
|
|
@@ -33,6 +36,8 @@ export type Parsed = {
|
|
|
33
36
|
sessionId?: string;
|
|
34
37
|
outputTokens?: number;
|
|
35
38
|
error?: string;
|
|
39
|
+
/** Set on failure only; `unknown` when no rule recognizes the output. */
|
|
40
|
+
kind?: ErrorKind;
|
|
36
41
|
};
|
|
37
42
|
/** Reads a runtime's stdout line by line while it runs, keeping only what it needs. */
|
|
38
43
|
export interface StreamParser {
|
package/dist/call.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Invoker } from "./adapters/index.js";
|
|
2
2
|
import { type ResolvedAgent, type ResolvedProject } from "./config.js";
|
|
3
|
+
import { type Tried } from "./fallback.js";
|
|
3
4
|
export declare const CALL_PROJECT = "(call)";
|
|
4
5
|
export declare const callsDir: (home: string) => string;
|
|
5
6
|
export interface CallOptions {
|
|
@@ -11,6 +12,8 @@ export interface CallOptions {
|
|
|
11
12
|
invoker?: Invoker;
|
|
12
13
|
signal?: AbortSignal;
|
|
13
14
|
say?: (line: string) => void;
|
|
15
|
+
/** Only used with a fallback chain, to wait out a cooldown; tests pass one that does not wait. */
|
|
16
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
14
17
|
}
|
|
15
18
|
export interface CallResult {
|
|
16
19
|
callId: string;
|
|
@@ -20,6 +23,8 @@ export interface CallResult {
|
|
|
20
23
|
/** The agent's final reply (empty when the call failed). */
|
|
21
24
|
text: string;
|
|
22
25
|
error?: string;
|
|
26
|
+
/** With a fallback chain: each model that failed, in order. */
|
|
27
|
+
tried?: Tried[];
|
|
23
28
|
/** Protected files the agent changed and agent-lyceum put back. */
|
|
24
29
|
reverted: string[];
|
|
25
30
|
}
|
package/dist/call.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { realInvoker } from "./adapters/index.js";
|
|
4
4
|
import { must } from "./assert.js";
|
|
5
5
|
import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
|
|
6
|
+
import { agentForCandidate, candidatesFor, Cooldowns, runWithFallback } from "./fallback.js";
|
|
6
7
|
import { atomicWrite } from "./fs-util.js";
|
|
7
8
|
import { ProtectedGuard } from "./guard.js";
|
|
8
9
|
import { assertName, homePaths, isInside } from "./paths.js";
|
|
@@ -137,23 +138,45 @@ export async function callAgent(o) {
|
|
|
137
138
|
const guard = new ProtectedGuard(project);
|
|
138
139
|
say(`Calling ${agent.name} in ${dir} (call ${callId})`);
|
|
139
140
|
const invoke = o.invoker ?? realInvoker;
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
const all = candidatesFor(agent);
|
|
142
|
+
const chain = all.length > 1;
|
|
143
|
+
// The primary logs to log/ as always; each backup to a directory of its own, so its run does not overwrite the primary's logs.
|
|
144
|
+
const logDirFor = (c) => (c.primary ? path.join(callDir, "log") : path.join(callDir, "log", `fallback-${all.indexOf(c)}`));
|
|
145
|
+
// A call is a single shot: no retries on one model, and cooldowns that live only as long as it does.
|
|
146
|
+
const outcome = await runWithFallback({
|
|
147
|
+
candidates: all,
|
|
148
|
+
legacy: !chain,
|
|
149
|
+
retry: 0,
|
|
150
|
+
cooldowns: new Cooldowns(),
|
|
151
|
+
cooldownSec: project.dispatcher.fallback_cooldown_sec,
|
|
147
152
|
signal: o.signal,
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
sleep: o.sleep,
|
|
154
|
+
onSwitch: (_from, to, kind) => {
|
|
155
|
+
if (o.signal?.aborted)
|
|
156
|
+
return;
|
|
157
|
+
say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
|
|
158
|
+
},
|
|
159
|
+
invoke: (c) => invoke({
|
|
160
|
+
project,
|
|
161
|
+
agent: agentForCandidate(agent, c),
|
|
162
|
+
workDir: callDir,
|
|
163
|
+
systemPrompt: buildSoloSystemPrompt(project, agent),
|
|
164
|
+
userPrompt: buildCallUserPrompt(agent, o.task),
|
|
165
|
+
timeoutSec: project.dispatcher.wake_timeout_sec,
|
|
166
|
+
signal: o.signal,
|
|
167
|
+
logDir: logDirFor(c),
|
|
168
|
+
}).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
169
|
+
});
|
|
170
|
+
const result = must(outcome.result, "the call's result");
|
|
171
|
+
const tried = chain ? outcome.tried : undefined;
|
|
150
172
|
const reverted = guard.check([agent]).map((v) => v.file);
|
|
151
173
|
for (const f of reverted)
|
|
152
174
|
say(`warn ${agent.name} changed ${f}; it was put back.`);
|
|
153
175
|
const cancelled = !!result.cancelled;
|
|
154
176
|
const error = result.ok ? undefined : cancelled ? "cancelled" : (result.error ?? (result.timedOut ? "timed out" : "failed"));
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
const triedList = !result.ok && !cancelled && tried?.length ? `\nTried:\n${tried.map((t) => `- ${t.model ? `${t.runtime}/${t.model}` : t.runtime} (${t.kind ?? "unknown"}): ${t.error ?? "failed"}`).join("\n")}\n` : "";
|
|
178
|
+
atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n${triedList}`);
|
|
179
|
+
return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, ...(tried ? { tried } : {}), reverted };
|
|
157
180
|
}
|
|
158
181
|
finally {
|
|
159
182
|
lease.release();
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ProjectPaths } from "./paths.js";
|
|
2
2
|
import type { AgentWorkspace } from "./worktree.js";
|
|
3
|
-
import { type DispatcherSettings, type Effort, type Runtime, type WebLevel } from "./schema.js";
|
|
3
|
+
import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
|
|
4
4
|
export declare class ConfigError extends Error {
|
|
5
5
|
}
|
|
6
6
|
export type Source = "global" | "project" | "default" | "model";
|
|
@@ -22,6 +22,15 @@ export interface ResolvedAgent {
|
|
|
22
22
|
canAskUser: boolean;
|
|
23
23
|
/** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
|
|
24
24
|
allowWeb?: WebLevel;
|
|
25
|
+
/** Ordered failover chain (runtime and model filled in); empty = no fallback. */
|
|
26
|
+
fallback: FallbackEntry[];
|
|
27
|
+
/** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
|
|
28
|
+
fallbackShared: boolean;
|
|
29
|
+
/** Entries of the chain that could not be resolved (left out of `fallback`); `validate` reports them. */
|
|
30
|
+
fallbackInvalid: {
|
|
31
|
+
index: number;
|
|
32
|
+
reason: "empty" | "no-runtime";
|
|
33
|
+
}[];
|
|
25
34
|
/** Where each field's value came from (for `validate` output). */
|
|
26
35
|
sources: Record<string, Source>;
|
|
27
36
|
}
|
|
@@ -65,6 +74,11 @@ export declare function loadGlobal(home: string): {
|
|
|
65
74
|
can_edit_agent_md?: boolean | undefined;
|
|
66
75
|
can_ask_user?: boolean | undefined;
|
|
67
76
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
77
|
+
fallback?: {
|
|
78
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
79
|
+
model?: string | undefined;
|
|
80
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
81
|
+
}[] | undefined;
|
|
68
82
|
owns?: unknown;
|
|
69
83
|
}>;
|
|
70
84
|
};
|
|
@@ -82,6 +96,12 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
82
96
|
strict?: boolean | undefined;
|
|
83
97
|
workspace_mode?: unknown;
|
|
84
98
|
log_max_bytes?: number | undefined;
|
|
99
|
+
fallback?: {
|
|
100
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
101
|
+
model?: string | undefined;
|
|
102
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
103
|
+
}[] | undefined;
|
|
104
|
+
fallback_cooldown_sec?: number | undefined;
|
|
85
105
|
};
|
|
86
106
|
agents: Record<string, {
|
|
87
107
|
runtime?: "claude-code" | "codex" | undefined;
|
|
@@ -97,6 +117,11 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
97
117
|
can_edit_agent_md?: boolean | undefined;
|
|
98
118
|
can_ask_user?: boolean | undefined;
|
|
99
119
|
allow_web?: "fetch" | "none" | "search" | undefined;
|
|
120
|
+
fallback?: {
|
|
121
|
+
runtime?: "claude-code" | "codex" | undefined;
|
|
122
|
+
model?: string | undefined;
|
|
123
|
+
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
124
|
+
}[] | undefined;
|
|
100
125
|
owns?: unknown;
|
|
101
126
|
}>;
|
|
102
127
|
};
|
package/dist/config.js
CHANGED
|
@@ -58,7 +58,7 @@ 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 { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, ...liveDispatcher } = p.dispatcher;
|
|
61
|
+
const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
|
|
62
62
|
const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
|
|
63
63
|
const agents = {};
|
|
64
64
|
for (const agentName of Object.keys(p.agents)) {
|
|
@@ -129,6 +129,21 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
129
129
|
break;
|
|
130
130
|
}
|
|
131
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);
|
|
132
147
|
const isLead = agentName === lead;
|
|
133
148
|
const resume = pick("resume");
|
|
134
149
|
const canMessage = pick("can_message");
|
|
@@ -156,6 +171,9 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
156
171
|
canEditAgentMd: canEdit ?? isLead,
|
|
157
172
|
canAskUser: canAsk ?? isLead,
|
|
158
173
|
allowWeb,
|
|
174
|
+
fallback,
|
|
175
|
+
fallbackShared,
|
|
176
|
+
fallbackInvalid,
|
|
159
177
|
sources,
|
|
160
178
|
};
|
|
161
179
|
}
|
|
@@ -170,6 +188,24 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
170
188
|
ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
|
|
171
189
|
};
|
|
172
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
|
+
}
|
|
173
209
|
/** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
|
|
174
210
|
function collectIgnored(globalFile, g, projectFile, p) {
|
|
175
211
|
const out = [];
|
|
@@ -223,7 +259,7 @@ export function findProjectForCwd(home, cwd) {
|
|
|
223
259
|
}
|
|
224
260
|
return best?.name;
|
|
225
261
|
}
|
|
226
|
-
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"];
|
|
227
263
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
228
264
|
export function resolveProjectWithSources(home, name) {
|
|
229
265
|
const project = resolveProject(home, name);
|
|
@@ -234,7 +270,10 @@ export function resolveProjectWithSources(home, name) {
|
|
|
234
270
|
dir: { file: projectFile, key: "dir" },
|
|
235
271
|
"team.lead": { file: projectFile, key: "team.lead" },
|
|
236
272
|
};
|
|
273
|
+
const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
|
|
237
274
|
for (const k of Object.keys(DISPATCHER_DEFAULTS)) {
|
|
275
|
+
if (k === "fallback_cooldown_sec" && !anyFallback)
|
|
276
|
+
continue;
|
|
238
277
|
sources[`dispatcher.${k}`] = raw.dispatcher[k] !== undefined ? { file: projectFile, key: `dispatcher.${k}` } : { default: true };
|
|
239
278
|
}
|
|
240
279
|
for (const a of Object.values(project.agents)) {
|
|
@@ -250,6 +289,8 @@ export function resolveProjectWithSources(home, name) {
|
|
|
250
289
|
const src = a.sources[f];
|
|
251
290
|
if (src === undefined)
|
|
252
291
|
continue; // not set anywhere (e.g. no effort)
|
|
292
|
+
if (f === "fallback" && a.fallback.length === 0)
|
|
293
|
+
continue;
|
|
253
294
|
if (src === "model")
|
|
254
295
|
sources[`agents.${a.name}.${f}`] = (() => {
|
|
255
296
|
const m = at(a.sources.model, "model");
|
|
@@ -258,6 +299,9 @@ export function resolveProjectWithSources(home, name) {
|
|
|
258
299
|
// A project-level AGENT.md found by convention is not a setting in project.yaml.
|
|
259
300
|
else if (f === "agent_md" && src === "project" && raw.agents[a.name]?.agent_md === undefined)
|
|
260
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" };
|
|
261
305
|
else
|
|
262
306
|
sources[`agents.${a.name}.${f}`] = at(src, f);
|
|
263
307
|
}
|
|
@@ -267,8 +311,12 @@ export function resolveProjectWithSources(home, name) {
|
|
|
267
311
|
/** Every effective setting under the same keys as `resolveProjectWithSources` reports sources for. */
|
|
268
312
|
export function flattenResolved(project) {
|
|
269
313
|
const v = { dir: project.dir, "team.lead": project.lead };
|
|
270
|
-
|
|
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
|
|
271
318
|
v[`dispatcher.${k}`] = val;
|
|
319
|
+
}
|
|
272
320
|
for (const a of Object.values(project.agents)) {
|
|
273
321
|
const p = `agents.${a.name}`;
|
|
274
322
|
v[`${p}.runtime`] = a.runtime;
|
|
@@ -282,6 +330,8 @@ export function flattenResolved(project) {
|
|
|
282
330
|
v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
|
|
283
331
|
v[`${p}.can_ask_user`] = a.canAskUser;
|
|
284
332
|
v[`${p}.allow_web`] = a.allowWeb;
|
|
333
|
+
if (a.fallback.length)
|
|
334
|
+
v[`${p}.fallback`] = a.fallback;
|
|
285
335
|
}
|
|
286
336
|
return v;
|
|
287
337
|
}
|
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
|
@@ -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 = {}) {
|
|
@@ -160,7 +169,7 @@ export async function diagnoseProject(project, opts = {}) {
|
|
|
160
169
|
}
|
|
161
170
|
for (const a of tidyAdvice(project))
|
|
162
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).` });
|
|
163
|
-
const runtimes = await probeProjectRuntimes(project, opts);
|
|
172
|
+
const runtimes = await probeProjectRuntimes(project, opts, true);
|
|
164
173
|
checks.push(...judgeRuntimes(project, runtimes));
|
|
165
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." });
|
|
166
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>>;
|