agent-lyceum 0.26.0 → 0.28.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 -0
- package/README.zh-TW.md +4 -0
- package/dist/adapters/claude.js +11 -0
- package/dist/adapters/codex.js +2 -0
- package/dist/call.js +5 -2
- package/dist/config.d.ts +37 -1
- package/dist/config.js +69 -4
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +14 -2
- package/dist/fallback.js +34 -6
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/mailbox.d.ts +14 -0
- package/dist/mailbox.js +67 -23
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +5 -2
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +80 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +30 -3
- package/dist/run-session.d.ts +2 -0
- package/dist/run-session.js +74 -11
- package/dist/run-store.d.ts +4 -1
- package/dist/sandbox-invocation.d.ts +35 -0
- package/dist/sandbox-invocation.js +186 -0
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.js +7 -1
- package/dist/schema.d.ts +42 -1
- package/dist/schema.js +24 -1
- package/dist/status.js +26 -1
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +33 -1
- package/dist/worktree.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,10 @@ agents:
|
|
|
75
75
|
|
|
76
76
|
Folder layout, every agent field and the rules `doctor` checks: [Configuration](docs/configuration.md).
|
|
77
77
|
|
|
78
|
+
## Downgrading
|
|
79
|
+
|
|
80
|
+
Older versions refuse a `team.yaml` or `project.yaml` that contains `model_tiers` (a load error, "Unrecognized key"). Before using an older binary, remove `model_tiers` from every `project.yaml` and from `team.yaml`. Other mail and run state are unaffected: older versions ignore a `tier` line in mail. See [Model tiers](docs/configuration.md#model-tiers). (Spec 24.3.2, 24.13)
|
|
81
|
+
|
|
78
82
|
## Topics
|
|
79
83
|
|
|
80
84
|
| Topic | Read |
|
package/README.zh-TW.md
CHANGED
|
@@ -75,6 +75,10 @@ agents:
|
|
|
75
75
|
|
|
76
76
|
資料夾結構、所有 agent 欄位與 `doctor` 檢查的規則:[設定](docs/configuration.zh-TW.md)。
|
|
77
77
|
|
|
78
|
+
## 降級(改用舊版)
|
|
79
|
+
|
|
80
|
+
舊版程式會拒絕載入含 `model_tiers` 的 `team.yaml` 或 `project.yaml`(載入錯誤,「Unrecognized key」)。改用舊版前,請先從每個 `project.yaml` 與 `team.yaml` 拿掉 `model_tiers`。其他信件與 run 狀態不受影響:舊版會忽略信件中的 `tier` 行。見[模型分級](docs/configuration.zh-TW.md#模型分級)。(spec 24.3.2、24.13)
|
|
81
|
+
|
|
78
82
|
## 主題
|
|
79
83
|
|
|
80
84
|
| 主題 | 閱讀 |
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
4
|
+
import { canonicalPath } from "../paths.js";
|
|
4
5
|
/** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
|
|
5
6
|
const rule = (tool, p) => `${tool}(/${p})`;
|
|
6
7
|
function denyRules(p) {
|
|
@@ -34,6 +35,15 @@ export function buildClaudeSettings(project, agent, override) {
|
|
|
34
35
|
for (const f of pol.allowFiles)
|
|
35
36
|
allow.push(rule("Edit", f));
|
|
36
37
|
const deny = pol.deny.flatMap(denyRules);
|
|
38
|
+
// Spec 25: network allowances; never with a policy override (a minimal-authority wake).
|
|
39
|
+
const sb = override ? undefined : agent.sandbox;
|
|
40
|
+
const network = {};
|
|
41
|
+
if (sb?.network.length)
|
|
42
|
+
network.allowedDomains = sb.network;
|
|
43
|
+
if (sb?.localBinding)
|
|
44
|
+
network.allowLocalBinding = true;
|
|
45
|
+
if (sb?.unixSockets.length)
|
|
46
|
+
network.allowUnixSockets = sb.unixSockets.map(canonicalPath);
|
|
37
47
|
return {
|
|
38
48
|
// The user's own and plugin hooks (e.g. a hook that asks the user a question) would block every tool call of a headless wake-up.
|
|
39
49
|
disableAllHooks: true,
|
|
@@ -45,6 +55,7 @@ export function buildClaudeSettings(project, agent, override) {
|
|
|
45
55
|
allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
|
|
46
56
|
denyWrite: pol.deny,
|
|
47
57
|
},
|
|
58
|
+
...(Object.keys(network).length ? { network } : {}),
|
|
48
59
|
},
|
|
49
60
|
};
|
|
50
61
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -63,6 +63,8 @@ export function buildCodexInvocation(input) {
|
|
|
63
63
|
'approval_policy="never"',
|
|
64
64
|
"-c",
|
|
65
65
|
`sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
|
|
66
|
+
// Codex has one switch for all network access; spec 25 never uses it under a policy override.
|
|
67
|
+
...(!input.policy && agent.sandbox?.network.length ? ["-c", "sandbox_workspace_write.network_access=true"] : []),
|
|
66
68
|
"--json",
|
|
67
69
|
"-o",
|
|
68
70
|
lastFile,
|
package/dist/call.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
|
|
3
4
|
import { realInvoker } from "./adapters/index.js";
|
|
4
5
|
import { must } from "./assert.js";
|
|
5
6
|
import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
|
|
@@ -55,6 +56,8 @@ export function callProject(home, agent, dir, callDir) {
|
|
|
55
56
|
lead: agent.name,
|
|
56
57
|
dispatcher: DISPATCHER_DEFAULTS,
|
|
57
58
|
agents: { [agent.name]: agent },
|
|
59
|
+
modelTiers: {},
|
|
60
|
+
modelTiersFrom: {},
|
|
58
61
|
paths,
|
|
59
62
|
solo: "call",
|
|
60
63
|
};
|
|
@@ -155,7 +158,7 @@ export async function callAgent(o) {
|
|
|
155
158
|
return;
|
|
156
159
|
say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
|
|
157
160
|
},
|
|
158
|
-
invoke: (c) => invoke
|
|
161
|
+
invoke: (c, ctx) => invokeWithSandboxChecks(invoke, {
|
|
159
162
|
project,
|
|
160
163
|
agent: agentForCandidate(agent, c),
|
|
161
164
|
workDir: callDir,
|
|
@@ -164,7 +167,7 @@ export async function callAgent(o) {
|
|
|
164
167
|
timeoutSec: project.dispatcher.wake_timeout_sec,
|
|
165
168
|
signal: o.signal,
|
|
166
169
|
logDir: logDirFor(c),
|
|
167
|
-
}).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
170
|
+
}, agent, { event: (name, fields) => say(JSON.stringify({ event: name, ...fields })) }, ctx.attempt).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
|
|
168
171
|
});
|
|
169
172
|
const result = must(outcome.result, "the call's result");
|
|
170
173
|
const tried = chain ? outcome.tried : undefined;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { type ProjectPaths } from "./paths.js";
|
|
2
2
|
import type { AgentWorkspace } from "./worktree.js";
|
|
3
|
-
import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
|
|
3
|
+
import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, 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";
|
|
7
|
+
export interface SandboxAllowances {
|
|
8
|
+
write: string[];
|
|
9
|
+
network: string[];
|
|
10
|
+
localBinding: boolean;
|
|
11
|
+
unixSockets: string[];
|
|
12
|
+
}
|
|
7
13
|
export interface ResolvedAgent {
|
|
8
14
|
name: string;
|
|
9
15
|
runtime?: Runtime;
|
|
@@ -22,6 +28,8 @@ export interface ResolvedAgent {
|
|
|
22
28
|
canAskUser: boolean;
|
|
23
29
|
/** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
|
|
24
30
|
allowWeb?: WebLevel;
|
|
31
|
+
/** Spec 25 allowances; unset when none is requested (the only switch that keeps output identical). Paths are absolute, `~` expanded, not yet canonical. */
|
|
32
|
+
sandbox?: SandboxAllowances;
|
|
25
33
|
/** Ordered failover chain (runtime and model filled in); empty = no fallback. */
|
|
26
34
|
fallback: FallbackEntry[];
|
|
27
35
|
/** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
|
|
@@ -33,6 +41,8 @@ export interface ResolvedAgent {
|
|
|
33
41
|
}[];
|
|
34
42
|
/** Where each field's value came from (for `validate` output). */
|
|
35
43
|
sources: Record<string, Source>;
|
|
44
|
+
/** Spec 24: the tiers this agent can run on (its runtime's ladder up to and including `model`); unset when nothing is below `model`. */
|
|
45
|
+
tiers?: string[];
|
|
36
46
|
/** Set when the run bound this agent as an instance of a role that has several (spec 23): which role, and the instance number. */
|
|
37
47
|
instance?: {
|
|
38
48
|
role: string;
|
|
@@ -52,6 +62,10 @@ export interface ResolvedProject {
|
|
|
52
62
|
lead: string;
|
|
53
63
|
dispatcher: DispatcherSettings;
|
|
54
64
|
agents: Record<string, ResolvedAgent>;
|
|
65
|
+
/** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
|
|
66
|
+
modelTiers: Partial<Record<Runtime, string[]>>;
|
|
67
|
+
/** Which file each ladder in `modelTiers` came from. */
|
|
68
|
+
modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
|
|
55
69
|
paths: ProjectPaths;
|
|
56
70
|
/** Removed settings still present in config files (ignored); `validate` warns once per file. */
|
|
57
71
|
ignoredSettings?: {
|
|
@@ -73,6 +87,10 @@ export interface ResolvedProject {
|
|
|
73
87
|
solo?: "run" | "call";
|
|
74
88
|
}
|
|
75
89
|
export declare function loadGlobal(home: string): {
|
|
90
|
+
model_tiers?: {
|
|
91
|
+
"claude-code"?: string[] | undefined;
|
|
92
|
+
codex?: string[] | undefined;
|
|
93
|
+
} | undefined;
|
|
76
94
|
agents: Record<string, {
|
|
77
95
|
runtime?: "claude-code" | "codex" | undefined;
|
|
78
96
|
model?: string | undefined;
|
|
@@ -92,6 +110,12 @@ export declare function loadGlobal(home: string): {
|
|
|
92
110
|
model?: string | undefined;
|
|
93
111
|
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
94
112
|
}[] | undefined;
|
|
113
|
+
sandbox?: {
|
|
114
|
+
write?: string[] | undefined;
|
|
115
|
+
network?: string[] | undefined;
|
|
116
|
+
local_binding?: boolean | undefined;
|
|
117
|
+
unix_sockets?: string[] | undefined;
|
|
118
|
+
} | undefined;
|
|
95
119
|
owns?: unknown;
|
|
96
120
|
}>;
|
|
97
121
|
};
|
|
@@ -100,6 +124,10 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
100
124
|
team: {
|
|
101
125
|
lead: string;
|
|
102
126
|
};
|
|
127
|
+
model_tiers?: {
|
|
128
|
+
"claude-code"?: string[] | undefined;
|
|
129
|
+
codex?: string[] | undefined;
|
|
130
|
+
} | undefined;
|
|
103
131
|
dispatcher: {
|
|
104
132
|
max_rounds?: number | undefined;
|
|
105
133
|
max_parallel?: unknown;
|
|
@@ -136,6 +164,12 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
136
164
|
model?: string | undefined;
|
|
137
165
|
effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
|
|
138
166
|
}[] | undefined;
|
|
167
|
+
sandbox?: {
|
|
168
|
+
write?: string[] | undefined;
|
|
169
|
+
network?: string[] | undefined;
|
|
170
|
+
local_binding?: boolean | undefined;
|
|
171
|
+
unix_sockets?: string[] | undefined;
|
|
172
|
+
} | undefined;
|
|
139
173
|
owns?: unknown;
|
|
140
174
|
}>;
|
|
141
175
|
};
|
|
@@ -163,6 +197,8 @@ export type SourceInfo = {
|
|
|
163
197
|
} | {
|
|
164
198
|
default: true;
|
|
165
199
|
};
|
|
200
|
+
/** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
|
|
201
|
+
export declare function resolveSandbox(agent: string, s: SandboxPartialT | undefined): SandboxAllowances | undefined;
|
|
166
202
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
167
203
|
export declare function resolveProjectWithSources(home: string, name: string): {
|
|
168
204
|
project: ResolvedProject;
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
|
-
import { absPath, homePaths, projectPaths, } from "./paths.js";
|
|
5
|
-
import {
|
|
4
|
+
import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
|
|
5
|
+
import { availableTiers } from "./tiers.js";
|
|
6
|
+
import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
|
|
6
7
|
export class ConfigError extends Error {
|
|
7
8
|
}
|
|
8
9
|
function readYaml(file, schema, label) {
|
|
@@ -60,6 +61,16 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
60
61
|
const lead = p.team.lead;
|
|
61
62
|
const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
|
|
62
63
|
const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
|
|
64
|
+
const modelTiers = {};
|
|
65
|
+
const modelTiersFrom = {};
|
|
66
|
+
for (const rt of RUNTIMES) {
|
|
67
|
+
const fromProject = p.model_tiers?.[rt] !== undefined;
|
|
68
|
+
const ladder = fromProject ? p.model_tiers?.[rt] : g.model_tiers?.[rt];
|
|
69
|
+
if (ladder?.length) {
|
|
70
|
+
modelTiers[rt] = [...ladder];
|
|
71
|
+
modelTiersFrom[rt] = fromProject ? "project" : "global";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
63
74
|
const agents = {};
|
|
64
75
|
for (const agentName of Object.keys(p.agents)) {
|
|
65
76
|
const gl = resolveLayer(g.agents[agentName], home);
|
|
@@ -144,12 +155,14 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
157
|
const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
|
|
158
|
+
const tiers = runtime ? availableTiers(modelTiers[runtime], model) : undefined;
|
|
147
159
|
const isLead = agentName === lead;
|
|
148
160
|
const resume = pick("resume");
|
|
149
161
|
const canMessage = pick("can_message");
|
|
150
162
|
const canEdit = pick("can_edit_agent_md");
|
|
151
163
|
const canAsk = pick("can_ask_user");
|
|
152
164
|
const allowWeb = pick("allow_web");
|
|
165
|
+
const sandbox = resolveSandbox(agentName, pick("sandbox"));
|
|
153
166
|
for (const [k, v] of [
|
|
154
167
|
["resume", resume],
|
|
155
168
|
["can_message", canMessage],
|
|
@@ -171,10 +184,12 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
171
184
|
canEditAgentMd: canEdit ?? isLead,
|
|
172
185
|
canAskUser: canAsk ?? isLead,
|
|
173
186
|
allowWeb,
|
|
187
|
+
...(sandbox ? { sandbox } : {}),
|
|
174
188
|
fallback,
|
|
175
189
|
fallbackShared,
|
|
176
190
|
fallbackInvalid,
|
|
177
191
|
sources,
|
|
192
|
+
...(tiers ? { tiers } : {}),
|
|
178
193
|
};
|
|
179
194
|
}
|
|
180
195
|
return {
|
|
@@ -184,6 +199,8 @@ export function resolveProjectFrom(home, name, g, p) {
|
|
|
184
199
|
lead,
|
|
185
200
|
dispatcher,
|
|
186
201
|
agents,
|
|
202
|
+
modelTiers,
|
|
203
|
+
modelTiersFrom,
|
|
187
204
|
paths,
|
|
188
205
|
ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
|
|
189
206
|
};
|
|
@@ -252,14 +269,46 @@ export function findProjectForCwd(home, cwd) {
|
|
|
252
269
|
let best;
|
|
253
270
|
for (const pr of listProjects(home)) {
|
|
254
271
|
const rel = path.relative(pr.dir, cwd);
|
|
255
|
-
if (
|
|
272
|
+
if (!escapesParent(rel)) {
|
|
256
273
|
if (!best || pr.dir.length > best.len)
|
|
257
274
|
best = { name: pr.name, len: pr.dir.length };
|
|
258
275
|
}
|
|
259
276
|
}
|
|
260
277
|
return best?.name;
|
|
261
278
|
}
|
|
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"];
|
|
279
|
+
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", "sandbox"];
|
|
280
|
+
/** Claude pattern metacharacters and control characters: refused in sandbox paths instead of escaped (spec 25.4.1). */
|
|
281
|
+
const PATH_CHARS = /[*?[\]{}()!\\]/;
|
|
282
|
+
const PATH_META = { test: (p) => PATH_CHARS.test(p) || [...p].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) };
|
|
283
|
+
function sandboxPath(agent, key, i, raw) {
|
|
284
|
+
const at = `agents.${agent}.sandbox.${key}.${i}`;
|
|
285
|
+
if (PATH_META.test(raw))
|
|
286
|
+
throw new ConfigError(`${at}: must not contain pattern characters (* ? [ ] { } ( ) !), backslashes or control characters (got ${JSON.stringify(raw)})`);
|
|
287
|
+
if (/^\$|\$\{?[A-Za-z_]/.test(raw))
|
|
288
|
+
throw new ConfigError(`${at}: environment variables are not expanded; write the absolute path (got "${raw}")`);
|
|
289
|
+
const expanded = expandHome(raw);
|
|
290
|
+
if (!path.isAbsolute(expanded))
|
|
291
|
+
throw new ConfigError(`${at}: must be an absolute path or start with ~/ (got "${raw}")`);
|
|
292
|
+
const resolved = path.resolve(expanded);
|
|
293
|
+
const canon = canonicalPath(resolved);
|
|
294
|
+
for (const p of [resolved, canon])
|
|
295
|
+
if (PATH_META.test(p))
|
|
296
|
+
throw new ConfigError(`${at}: resolved path contains pattern characters (${JSON.stringify(p)})`);
|
|
297
|
+
return resolved;
|
|
298
|
+
}
|
|
299
|
+
/** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
|
|
300
|
+
export function resolveSandbox(agent, s) {
|
|
301
|
+
if (!s)
|
|
302
|
+
return undefined;
|
|
303
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
304
|
+
const write = uniq((s.write ?? []).map((p, i) => sandboxPath(agent, "write", i, p)));
|
|
305
|
+
const unixSockets = uniq((s.unix_sockets ?? []).map((p, i) => sandboxPath(agent, "unix_sockets", i, p)));
|
|
306
|
+
const network = uniq(s.network ?? []);
|
|
307
|
+
const localBinding = s.local_binding === true;
|
|
308
|
+
if (!write.length && !unixSockets.length && !network.length && !localBinding)
|
|
309
|
+
return undefined;
|
|
310
|
+
return { write, network, localBinding, unixSockets };
|
|
311
|
+
}
|
|
263
312
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
264
313
|
export function resolveProjectWithSources(home, name) {
|
|
265
314
|
const project = resolveProject(home, name);
|
|
@@ -278,6 +327,11 @@ export function resolveProjectWithSources(home, name) {
|
|
|
278
327
|
}
|
|
279
328
|
if (raw.dispatcher.max_instances !== undefined)
|
|
280
329
|
sources["dispatcher.max_instances"] = { file: projectFile, key: "dispatcher.max_instances" };
|
|
330
|
+
for (const rt of RUNTIMES) {
|
|
331
|
+
const from = project.modelTiersFrom[rt];
|
|
332
|
+
if (from)
|
|
333
|
+
sources[`model_tiers.${rt}`] = { file: from === "project" ? projectFile : globalFile, key: `model_tiers.${rt}` };
|
|
334
|
+
}
|
|
281
335
|
for (const a of Object.values(project.agents)) {
|
|
282
336
|
const at = (src, field) => {
|
|
283
337
|
const key = `agents.${a.name}.${field}`;
|
|
@@ -307,6 +361,10 @@ export function resolveProjectWithSources(home, name) {
|
|
|
307
361
|
else
|
|
308
362
|
sources[`agents.${a.name}.${f}`] = at(src, f);
|
|
309
363
|
}
|
|
364
|
+
// The ladder is the runtime's, so its source is the ladder's own key (spec 24.9).
|
|
365
|
+
const ladderSrc = a.tiers ? sources[`model_tiers.${a.runtime}`] : undefined;
|
|
366
|
+
if (ladderSrc)
|
|
367
|
+
sources[`agents.${a.name}.model_tiers`] = ladderSrc;
|
|
310
368
|
}
|
|
311
369
|
return { project, sources };
|
|
312
370
|
}
|
|
@@ -314,6 +372,9 @@ export function resolveProjectWithSources(home, name) {
|
|
|
314
372
|
export function flattenResolved(project) {
|
|
315
373
|
const v = { dir: project.dir, "team.lead": project.lead };
|
|
316
374
|
const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
|
|
375
|
+
for (const rt of RUNTIMES)
|
|
376
|
+
if (project.modelTiers[rt])
|
|
377
|
+
v[`model_tiers.${rt}`] = project.modelTiers[rt];
|
|
317
378
|
for (const [k, val] of Object.entries(project.dispatcher)) {
|
|
318
379
|
if (k === "fallback_cooldown_sec" && !anyFallback)
|
|
319
380
|
continue; // keep `doctor --resolved` unchanged without fallback
|
|
@@ -334,6 +395,10 @@ export function flattenResolved(project) {
|
|
|
334
395
|
v[`${p}.allow_web`] = a.allowWeb;
|
|
335
396
|
if (a.fallback.length)
|
|
336
397
|
v[`${p}.fallback`] = a.fallback;
|
|
398
|
+
if (a.sandbox)
|
|
399
|
+
v[`${p}.sandbox`] = { write: a.sandbox.write, network: a.sandbox.network, local_binding: a.sandbox.localBinding, unix_sockets: a.sandbox.unixSockets };
|
|
400
|
+
if (a.tiers)
|
|
401
|
+
v[`${p}.model_tiers`] = a.tiers;
|
|
337
402
|
}
|
|
338
403
|
return v;
|
|
339
404
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
|
|
2
|
+
export declare const MAX_DUPLICATE_NOTICES = 2;
|
|
3
|
+
/** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
|
|
4
|
+
export declare function duplicateNoticeKey(sender: string, repeats: Iterable<[string, string]>): string;
|
|
5
|
+
/**
|
|
6
|
+
* Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
|
|
7
|
+
* are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldNotifyDuplicate(runDir: string, key: string, source: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
/** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
|
|
5
|
+
export const MAX_DUPLICATE_NOTICES = 2;
|
|
6
|
+
const FILE = "duplicate-notices.json";
|
|
7
|
+
/** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
|
|
8
|
+
export function duplicateNoticeKey(sender, repeats) {
|
|
9
|
+
return JSON.stringify([sender, [...repeats].map(([to, id]) => [to, id]).sort()]);
|
|
10
|
+
}
|
|
11
|
+
function load(runDir) {
|
|
12
|
+
try {
|
|
13
|
+
const data = JSON.parse(fs.readFileSync(path.join(runDir, FILE), "utf8"));
|
|
14
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
|
|
22
|
+
* are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
|
|
23
|
+
*/
|
|
24
|
+
export function shouldNotifyDuplicate(runDir, key, source) {
|
|
25
|
+
const all = load(runDir);
|
|
26
|
+
const seen = Array.isArray(all[key]) ? all[key] : [];
|
|
27
|
+
if (seen.includes(source))
|
|
28
|
+
return true;
|
|
29
|
+
if (seen.length >= MAX_DUPLICATE_NOTICES)
|
|
30
|
+
return false;
|
|
31
|
+
all[key] = [...seen, source];
|
|
32
|
+
atomicWrite(path.join(runDir, FILE), `${JSON.stringify(all, null, 2)}\n`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
package/dist/fallback.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface Candidate {
|
|
|
7
7
|
model?: string;
|
|
8
8
|
effort?: Effort;
|
|
9
9
|
primary: boolean;
|
|
10
|
+
/** The cheaper model a lead's `tier` picked (spec 24.7): not the primary, and not a fallback either. */
|
|
11
|
+
tiered?: true;
|
|
10
12
|
/** `<runtime>/<model>`; the cooldown and session key. */
|
|
11
13
|
key: string;
|
|
12
14
|
}
|
|
@@ -14,7 +16,7 @@ export interface Candidate {
|
|
|
14
16
|
* 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
17
|
* earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
|
|
16
18
|
*/
|
|
17
|
-
export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
|
|
19
|
+
export declare function candidatesFor(agent: ResolvedAgent, tierModel?: string): Candidate[];
|
|
18
20
|
/**
|
|
19
21
|
* 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
22
|
* and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
|
|
@@ -39,7 +41,13 @@ export declare class Cooldowns {
|
|
|
39
41
|
}>): Cooldowns;
|
|
40
42
|
}
|
|
41
43
|
/** 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(
|
|
44
|
+
export declare function planAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
|
|
45
|
+
/**
|
|
46
|
+
* The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
|
|
47
|
+
* waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
|
|
48
|
+
* A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deferredAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
|
|
43
51
|
export type Step = "retry-same" | "next-candidate" | "stop";
|
|
44
52
|
/**
|
|
45
53
|
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
@@ -78,6 +86,8 @@ export interface Tried {
|
|
|
78
86
|
export interface RunWithFallbackOptions<R extends AttemptResult> {
|
|
79
87
|
/** From `planAttempts`; tried in order. */
|
|
80
88
|
candidates: Candidate[];
|
|
89
|
+
/** From `deferredAttempts`: tried after `candidates`, each one only once its cooldown has ended. */
|
|
90
|
+
deferred?: Candidate[];
|
|
81
91
|
/** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
|
|
82
92
|
legacy: boolean;
|
|
83
93
|
/** Retries per candidate (`dispatcher.retry`). */
|
|
@@ -91,6 +101,8 @@ export interface RunWithFallbackOptions<R extends AttemptResult> {
|
|
|
91
101
|
/** Runs after each call, awaited before the next decision. */
|
|
92
102
|
afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
|
|
93
103
|
onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
|
|
104
|
+
/** Runs before the loop sleeps `ms` for `candidate`'s cooldown to end. */
|
|
105
|
+
onCooldownWait?(candidate: Candidate, ms: number): void;
|
|
94
106
|
sleep?(ms: number, signal?: AbortSignal): Promise<void>;
|
|
95
107
|
now?(): number;
|
|
96
108
|
signal?: AbortSignal;
|
package/dist/fallback.js
CHANGED
|
@@ -4,12 +4,18 @@ const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase
|
|
|
4
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
5
|
* earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
|
|
6
6
|
*/
|
|
7
|
-
export function candidatesFor(agent) {
|
|
7
|
+
export function candidatesFor(agent, tierModel) {
|
|
8
8
|
// `validate` refuses an agent without a runtime before anything is woken.
|
|
9
9
|
const runtime = agent.runtime;
|
|
10
10
|
const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
|
|
11
11
|
const out = [primary];
|
|
12
12
|
const seen = new Set([primary.key]);
|
|
13
|
+
// A tier's cheaper model goes first (spec 24.7.1); one that is the ceiling itself adds nothing.
|
|
14
|
+
if (tierModel && keyOf(runtime, tierModel) !== primary.key) {
|
|
15
|
+
const key = keyOf(runtime, tierModel);
|
|
16
|
+
out.unshift({ runtime, model: tierModel, effort: agent.effort, primary: false, tiered: true, key });
|
|
17
|
+
seen.add(key);
|
|
18
|
+
}
|
|
13
19
|
for (const f of agent.fallback) {
|
|
14
20
|
const model = f.model || undefined;
|
|
15
21
|
const key = keyOf(f.runtime, model);
|
|
@@ -28,6 +34,8 @@ export function candidatesFor(agent) {
|
|
|
28
34
|
export function agentForCandidate(agent, c) {
|
|
29
35
|
if (c.primary)
|
|
30
36
|
return agent;
|
|
37
|
+
if (c.tiered)
|
|
38
|
+
return { ...agent, model: c.model };
|
|
31
39
|
const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
|
|
32
40
|
return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
|
|
33
41
|
}
|
|
@@ -63,10 +71,19 @@ export class Cooldowns {
|
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
/** 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(
|
|
67
|
-
const all = candidatesFor(agent);
|
|
74
|
+
export function planAttempts(all, cooldowns, now) {
|
|
68
75
|
const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
|
|
69
|
-
return warm.length ? warm : all;
|
|
76
|
+
return warm.length ? warm : [...all];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
|
|
80
|
+
* waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
|
|
81
|
+
* A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
|
|
82
|
+
*/
|
|
83
|
+
export function deferredAttempts(all, cooldowns, now) {
|
|
84
|
+
return all
|
|
85
|
+
.filter((c) => !c.tiered && cooldowns.active(c.key, now) > 0)
|
|
86
|
+
.sort((a, b) => cooldowns.active(a.key, now) - cooldowns.active(b.key, now));
|
|
70
87
|
}
|
|
71
88
|
/**
|
|
72
89
|
* What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
|
|
@@ -74,6 +91,9 @@ export function planAttempts(agent, cooldowns, now) {
|
|
|
74
91
|
* keeps the old loop: every kind is retried `retry` times, then the wake fails.
|
|
75
92
|
*/
|
|
76
93
|
export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
|
|
94
|
+
// A refused configuration cannot get better by trying again or on another model (spec 25.19.1).
|
|
95
|
+
if (kind === "config")
|
|
96
|
+
return "stop";
|
|
77
97
|
const retriesLeft = attemptsOnCurrent <= retry;
|
|
78
98
|
if (legacy)
|
|
79
99
|
return retriesLeft ? "retry-same" : "stop";
|
|
@@ -115,7 +135,8 @@ export function defaultSleep(ms, signal) {
|
|
|
115
135
|
* retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
|
|
116
136
|
*/
|
|
117
137
|
export async function runWithFallback(o) {
|
|
118
|
-
const
|
|
138
|
+
const list = [...o.candidates, ...(o.deferred ?? [])];
|
|
139
|
+
const first = list[0];
|
|
119
140
|
if (!first)
|
|
120
141
|
throw new Error("runWithFallback needs at least one candidate");
|
|
121
142
|
const sleep = o.sleep ?? defaultSleep;
|
|
@@ -135,7 +156,7 @@ export async function runWithFallback(o) {
|
|
|
135
156
|
tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
|
|
136
157
|
if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
|
|
137
158
|
o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
|
|
138
|
-
const next =
|
|
159
|
+
const next = list[index + 1];
|
|
139
160
|
const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
|
|
140
161
|
if (step === "stop")
|
|
141
162
|
return { result, tried, used };
|
|
@@ -150,6 +171,13 @@ export async function runWithFallback(o) {
|
|
|
150
171
|
}
|
|
151
172
|
const to = next;
|
|
152
173
|
o.onSwitch?.(used, to, result.kind, result);
|
|
174
|
+
const wait = o.legacy ? 0 : o.cooldowns.active(to.key, now());
|
|
175
|
+
if (wait > 0) {
|
|
176
|
+
o.onCooldownWait?.(to, wait);
|
|
177
|
+
await sleep(wait, o.signal);
|
|
178
|
+
if (o.signal?.aborted)
|
|
179
|
+
return { result, tried, used, stopped: "aborted" };
|
|
180
|
+
}
|
|
153
181
|
ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
|
|
154
182
|
index++;
|
|
155
183
|
used = to;
|
package/dist/guard.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface Violation {
|
|
|
9
9
|
/** The kept copy is cut to MAX_KEEP_BYTES. */
|
|
10
10
|
truncated?: boolean;
|
|
11
11
|
}
|
|
12
|
+
/** A change to a cross-run shared file that no agent of the batch may make, while another run of the project was live. */
|
|
13
|
+
export interface Unattributed {
|
|
14
|
+
file: string;
|
|
15
|
+
agents: string[];
|
|
16
|
+
otherRuns: string[];
|
|
17
|
+
}
|
|
12
18
|
export declare const MAX_KEEP_BYTES: number;
|
|
13
19
|
/**
|
|
14
20
|
* Detects (and reverts) changes to protected files made by agents that may not edit them.
|
|
@@ -16,10 +22,20 @@ export declare const MAX_KEEP_BYTES: number;
|
|
|
16
22
|
*/
|
|
17
23
|
export declare class ProtectedGuard {
|
|
18
24
|
private project;
|
|
25
|
+
private otherLiveRuns;
|
|
19
26
|
private baseline;
|
|
20
27
|
/** Regular files of the records tree (including `.archive/`); only the lead may change them. */
|
|
21
28
|
private records;
|
|
22
|
-
|
|
29
|
+
/** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
|
|
30
|
+
private unattributed;
|
|
31
|
+
/** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
|
|
32
|
+
private liveAtBase;
|
|
33
|
+
/** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
|
|
34
|
+
constructor(project: ResolvedProject, otherLiveRuns?: () => string[]);
|
|
35
|
+
/** The unattributed changes since the last call (they were kept and adopted as the baseline). */
|
|
36
|
+
takeUnattributed(): Unattributed[];
|
|
37
|
+
/** Files shared by every run of the project: only these can be changed by another run's lead. */
|
|
38
|
+
private crossRunShared;
|
|
23
39
|
/**
|
|
24
40
|
* Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
|
|
25
41
|
* judged: edits made in between (you, or another run of the project) are not the batch's doing.
|