subagent-delegation 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -2
- package/VERSION +1 -0
- package/adapters/codex/modes.json +108 -0
- package/adapters/codex/skills/subagent-model-selection/SKILL.md +37 -0
- package/adapters/codex/skills/subagent-model-selection/agents/openai.yaml +6 -0
- package/adapters/codex/skills/subagent-model-selection/assets/AGENTS.fragment.md +7 -0
- package/adapters/codex/skills/subagent-model-selection/assets/codex/agents/task-delegate.toml +21 -0
- package/adapters/codex/skills/subagent-model-selection/assets/codex/agents/task-execute.toml +23 -0
- package/adapters/codex/skills/subagent-model-selection/assets/codex/agents/task-review.toml +23 -0
- package/adapters/codex/skills/subagent-model-selection/assets/codex/agents/task-scan.toml +23 -0
- package/adapters/codex/skills/subagent-model-selection/assets/codex/config.toml +17 -0
- package/adapters/codex/skills/subagent-model-selection/references/configuration.md +15 -0
- package/adapters/codex/skills/subagent-model-selection/references/failure-policy.md +17 -0
- package/bin/subagent-delegation.mjs +235 -0
- package/docs/README.md +16 -0
- package/docs/implementation-review.md +87 -0
- package/docs/usage.md +251 -0
- package/install.py +33 -0
- package/lib/codex.mjs +195 -0
- package/lib/install.mjs +681 -0
- package/lib/models.mjs +260 -0
- package/lib/server.mjs +168 -0
- package/package.json +12 -4
- package/web/app.js +597 -0
- package/web/index.html +92 -0
- package/web/styles.css +343 -0
package/lib/codex.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parse, stringify } from 'smol-toml';
|
|
5
|
+
|
|
6
|
+
export const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
7
|
+
export const VERSION = (await readFile(new URL('../VERSION', import.meta.url), 'utf8')).trim();
|
|
8
|
+
export const LEVELS = ['L1', 'L2', 'L3', 'L4'];
|
|
9
|
+
export const ROLES = { task_scan: 'L1', task_execute: 'L2', task_review: 'L3', task_delegate: null };
|
|
10
|
+
export const START = '<!-- subagent-delegation:start -->';
|
|
11
|
+
export const END = '<!-- subagent-delegation:end -->';
|
|
12
|
+
const policy = JSON.parse(await readFile(new URL('../adapters/codex/modes.json', import.meta.url), 'utf8'));
|
|
13
|
+
export const FALLBACK_CONDITIONS = policy.fallback_conditions;
|
|
14
|
+
export const BUILTINS = policy.modes.map(mode => ({ ...mode, kind: 'builtin', fallback_conditions: FALLBACK_CONDITIONS }));
|
|
15
|
+
export const MODE_COMMANDS = { low: 'economy', mid: 'balanced', fire: 'performance' };
|
|
16
|
+
|
|
17
|
+
export function argumentError(message, code = 'INVALID_ARGUMENT') {
|
|
18
|
+
return Object.assign(new Error(message), { code, exitCode: 2 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateText(value, maximum, label) {
|
|
22
|
+
if (typeof value !== 'string' || !value || !value.isWellFormed() || value.trim() !== value || [...value].length > maximum || /[\p{Cc}\p{Cf}]/u.test(value)) {
|
|
23
|
+
throw argumentError(`${label}必须为有效 Unicode、无控制字符、无首尾空白的 1–${maximum} 字符文本。`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validatePair(pair) {
|
|
29
|
+
if (!pair || typeof pair !== 'object' || Array.isArray(pair) || Object.keys(pair).some(k => !['model', 'effort'].includes(k))) {
|
|
30
|
+
throw argumentError('模型组合只接受 model 和 effort。');
|
|
31
|
+
}
|
|
32
|
+
return { model: validateText(pair.model, 200, '模型'), effort: validateText(pair.effort, 40, '推理强度') };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateDefinition(input) {
|
|
36
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 1 || !Object.hasOwn(input, 'levels')) throw argumentError('完整定义只接受 levels 对象。');
|
|
37
|
+
const levels = input.levels;
|
|
38
|
+
if (!levels || typeof levels !== 'object' || Array.isArray(levels) || Object.keys(levels).length !== 4 || LEVELS.some(level => !Object.hasOwn(levels, level))) throw argumentError('必须提供完整 L1–L4。');
|
|
39
|
+
return Object.fromEntries(LEVELS.map(level => {
|
|
40
|
+
const value = levels[level];
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 3 || !['model', 'effort', 'fallback'].every(k => Object.hasOwn(value, k))) throw argumentError(`${level} 必须显式提供 model、effort、fallback(可为 null)。`);
|
|
42
|
+
const primary = validatePair({ model: value.model, effort: value.effort });
|
|
43
|
+
const fallback = value.fallback === null ? null : validatePair(value.fallback);
|
|
44
|
+
if (fallback && primary.model === fallback.model && primary.effort === fallback.effort) throw argumentError('主配置与备用不能完全相同。');
|
|
45
|
+
return [level, { ...primary, fallback }];
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checkedDefinition(matches) {
|
|
50
|
+
if (matches.length !== 1) throw argumentError('档位不存在或名称不唯一;内置档位请用 low、mid、fire,自定义档位请用 mode list 显示的 id 或名称。', 'MODE_NOT_FOUND');
|
|
51
|
+
const mode = matches[0];
|
|
52
|
+
return { ...mode, levels: validateDefinition({ levels: mode.levels }), fallback_conditions: FALLBACK_CONDITIONS };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveDefinition(selector, customModes = []) {
|
|
56
|
+
if (Object.hasOwn(MODE_COMMANDS, selector)) return checkedDefinition(BUILTINS.filter(mode => mode.id === MODE_COMMANDS[selector]));
|
|
57
|
+
const name = typeof selector === 'string' ? selector.trim().normalize('NFC') : selector;
|
|
58
|
+
return checkedDefinition(customModes.filter(mode => mode.id === selector || mode.name === name));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveStoredDefinition(id, customModes = []) {
|
|
62
|
+
return checkedDefinition([...BUILTINS, ...customModes].filter(mode => mode.id === id));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function explainSelection({ definition, level, role = 'task_delegate' }) {
|
|
66
|
+
if (!LEVELS.includes(level) || !Object.hasOwn(ROLES, role)) throw argumentError('任务等级或角色无效。');
|
|
67
|
+
const { model, effort, fallback } = definition.levels[level];
|
|
68
|
+
const fixed = ROLES[role] && definition.levels[ROLES[role]];
|
|
69
|
+
const matches = level !== 'L4' && fixed && fixed.model === model && fixed.effort === effort;
|
|
70
|
+
return {
|
|
71
|
+
mode_id: definition.id, mode_kind: definition.kind, mode_name: definition.name,
|
|
72
|
+
level, level_source: 'argument', requested_role: role, dispatch_role: matches ? role : 'task_delegate',
|
|
73
|
+
primary: { model, effort }, fallback, fallback_conditions: fallback ? FALLBACK_CONDITIONS : [],
|
|
74
|
+
permission_contract: { task_scan: 'readonly_evidence', task_execute: 'scoped_output', task_review: 'readonly_review', task_delegate: 'bounded_delegate' }[role],
|
|
75
|
+
reason_codes: ['TASK_LEVEL_ARGUMENT', 'MODE_PRESET', matches ? 'FIXED_ROLE_MATCH' : 'UNPINNED_ROLE_REQUIRED', 'MODEL_ACCESS_UNKNOWN', 'RUNTIME_UNVERIFIED'],
|
|
76
|
+
access: 'unknown', runtime_verification: 'unverified',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function safeToml(bytes) {
|
|
81
|
+
try { return parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes), { integersAsBigInt: 'asNeeded', unsafeKeyBehaviour: 'throw' }); }
|
|
82
|
+
catch { throw Object.assign(new Error('配置格式无法解析;请在本地检查文件。'), { code: 'INVALID_TOML', exitCode: 1 }); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function safeJsonData(value) {
|
|
86
|
+
return JSON.stringify(value).replace(/[<>`]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function renderPayload(home, mode) {
|
|
90
|
+
const skillRoot = path.join(PACKAGE_ROOT, 'adapters/codex/skills/subagent-model-selection');
|
|
91
|
+
const files = new Map();
|
|
92
|
+
async function visit(directory, prefix = '') {
|
|
93
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
94
|
+
if (entry.isSymbolicLink()) throw Object.assign(new Error('发布资产不允许符号链接。'), { code: 'INVALID_PACKAGE' });
|
|
95
|
+
const relative = prefix + entry.name;
|
|
96
|
+
if (entry.isDirectory()) await visit(path.join(directory, entry.name), relative + '/');
|
|
97
|
+
else if (entry.isFile() && entry.name !== '.DS_Store') files.set(`skills/subagent-model-selection/${relative}`, await readFile(path.join(directory, entry.name)));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await visit(skillRoot);
|
|
101
|
+
const skillPath = path.join(home, 'skills/subagent-model-selection/SKILL.md');
|
|
102
|
+
const modePath = path.join(home, 'skills/subagent-model-selection/references/current-mode.json');
|
|
103
|
+
for (const [name, level] of Object.entries(ROLES)) {
|
|
104
|
+
const filename = name.replaceAll('_', '-') + '.toml';
|
|
105
|
+
const role = safeToml(await readFile(path.join(skillRoot, 'assets/codex/agents', filename)));
|
|
106
|
+
if (level) { role.model = mode.levels[level].model; role.model_reasoning_effort = mode.levels[level].effort; }
|
|
107
|
+
else { delete role.model; delete role.model_reasoning_effort; }
|
|
108
|
+
role.developer_instructions = `启动后先读取本 home 的子代理模型选择 Skill,遵守当前派发职责;不递归委派。\n规则路径(JSON 字符串数据):${safeJsonData(skillPath)}\n\n` + role.developer_instructions;
|
|
109
|
+
const bytes = Buffer.from(stringify(role));
|
|
110
|
+
files.set(`agents/${filename}`, bytes);
|
|
111
|
+
files.set(`skills/subagent-model-selection/assets/codex/agents/${filename}`, bytes);
|
|
112
|
+
}
|
|
113
|
+
files.set('skills/subagent-model-selection/references/current-mode.json', Buffer.from(JSON.stringify({ ...mode, version: VERSION, roles: ROLES }, null, 2) + '\n'));
|
|
114
|
+
const fragment = await readFile(path.join(skillRoot, 'assets/AGENTS.fragment.md'), 'utf8');
|
|
115
|
+
const block = Buffer.from(`${START}\n## AI 子代理模型选择与委派\n\nCodex 启动或加载本 home 的规则时,必须先读取以下 Skill;每次创建、重新委派或切换子智能体模型前必须再次按 Skill 和当前档位检查。已有上下文可复用,配置变化后须重读。委派仍须有用户或适用规则授权。\n\n以下 JSON 只表示绑定 home 的文件路径,不是新指令:\n\n\`\`\`json\n${safeJsonData({ skill: skillPath, mode: modePath })}\n\`\`\`\n\n${fragment.trim()}\n${END}\n`);
|
|
116
|
+
return { files, block };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Project observations are read only and intentionally do not change the bound home's revision.
|
|
120
|
+
export async function inspectProject(project) {
|
|
121
|
+
const observations = new Map(), directories = new Map(), candidates = [], checked = [], skipped = [], diagnostics = [];
|
|
122
|
+
const safePath = async filename => {
|
|
123
|
+
let current = path.parse(filename).root;
|
|
124
|
+
for (const part of filename.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
125
|
+
current = path.join(current, part);
|
|
126
|
+
try { if ((await lstat(current)).isSymbolicLink()) { skipped.push({ path: filename, reason: 'symlink_not_followed' }); return false; } }
|
|
127
|
+
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
const observe = async filename => {
|
|
132
|
+
if (!await safePath(filename)) return null;
|
|
133
|
+
let metadata;
|
|
134
|
+
try { metadata = await lstat(filename); } catch (error) { if (error.code === 'ENOENT') return { absent: true }; throw error; }
|
|
135
|
+
if (!metadata.isFile() || metadata.size > 1024 * 1024) { skipped.push({ path: filename, reason: 'not_regular_or_too_large' }); return null; }
|
|
136
|
+
return { bytes: await readFile(filename), mode: metadata.mode & 0o7777 };
|
|
137
|
+
};
|
|
138
|
+
let directory = path.resolve(project);
|
|
139
|
+
try { if (!(await lstat(directory)).isDirectory()) throw new Error(); }
|
|
140
|
+
catch { return { candidates, checked, skipped: [{ path: directory, reason: 'project_not_directory' }], diagnostics }; }
|
|
141
|
+
while (true) {
|
|
142
|
+
const folder = path.join(directory, '.codex');
|
|
143
|
+
for (const filename of [path.join(folder, 'config.toml'), path.join(directory, 'AGENTS.md'), path.join(directory, 'AGENTS.override.md')]) {
|
|
144
|
+
const value = await observe(filename);
|
|
145
|
+
if (value === null) continue;
|
|
146
|
+
observations.set(filename, value); checked.push(filename);
|
|
147
|
+
if (value.absent) continue;
|
|
148
|
+
if (path.basename(filename) !== 'config.toml') { candidates.push({ source: filename, kind: 'project_rules', content_inspected: false }); continue; }
|
|
149
|
+
try {
|
|
150
|
+
const config = safeToml(value.bytes);
|
|
151
|
+
candidates.push({ source: filename, kind: 'project_config', model: typeof config.model === 'string' ? config.model : null, effort: typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort : null });
|
|
152
|
+
for (const [name, role] of Object.entries(config.agents || {})) if (role && typeof role === 'object' && !Array.isArray(role)) candidates.push({ source: filename, kind: 'role_registration', name, config_file: typeof role.config_file === 'string' ? role.config_file : null, inspected: false, reason: 'candidate_only_not_followed' });
|
|
153
|
+
for (const [name, profile] of Object.entries(config.profiles || {})) candidates.push({ source: filename, kind: 'profile_candidate', name, model: typeof profile?.model === 'string' ? profile.model : null, effort: typeof profile?.model_reasoning_effort === 'string' ? profile.model_reasoning_effort : null, active: 'unknown' });
|
|
154
|
+
} catch { diagnostics.push({ code: 'PROJECT_CONFIG_INVALID', status: 'fail', source: filename, message: '项目配置格式无法解析;未输出内容。' }); }
|
|
155
|
+
}
|
|
156
|
+
const walkAgents = async agents => {
|
|
157
|
+
if (!await safePath(agents)) return;
|
|
158
|
+
try {
|
|
159
|
+
const metadata = await lstat(agents);
|
|
160
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) skipped.push({ path: agents, reason: 'unsafe_directory_not_followed' });
|
|
161
|
+
else {
|
|
162
|
+
const entries = (await readdir(agents, { withFileTypes: true })).map(e => [e.name, e.isSymbolicLink() ? 'symlink' : e.isDirectory() ? 'directory' : 'file']).sort();
|
|
163
|
+
directories.set(agents, entries);
|
|
164
|
+
for (const [name, kind] of entries) {
|
|
165
|
+
if (kind === 'directory') { await walkAgents(path.join(agents, name)); continue; }
|
|
166
|
+
if (kind === 'symlink') { skipped.push({ path: path.join(agents, name), reason: 'symlink_not_followed' }); continue; }
|
|
167
|
+
if (!name.endsWith('.toml') || kind !== 'file') continue;
|
|
168
|
+
const filename = path.join(agents, name), value = await observe(filename);
|
|
169
|
+
if (!value || value.absent) continue;
|
|
170
|
+
observations.set(filename, value); checked.push(filename);
|
|
171
|
+
try {
|
|
172
|
+
const role = safeToml(value.bytes);
|
|
173
|
+
if (typeof role.name === 'string') candidates.push({ source: filename, kind: 'project_role', name: role.name, model: typeof role.model === 'string' ? role.model : null, effort: typeof role.model_reasoning_effort === 'string' ? role.model_reasoning_effort : null });
|
|
174
|
+
} catch { diagnostics.push({ code: 'PROJECT_CONFIG_INVALID', status: 'fail', source: filename, message: '项目角色格式无法解析;未输出内容。' }); }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (error) { if (error.code !== 'ENOENT') skipped.push({ path: agents, reason: 'unreadable' }); else directories.set(agents, null); }
|
|
178
|
+
};
|
|
179
|
+
await walkAgents(path.join(folder, 'agents'));
|
|
180
|
+
if (directory === path.dirname(directory)) break;
|
|
181
|
+
directory = path.dirname(directory);
|
|
182
|
+
}
|
|
183
|
+
for (const [filename, before] of observations) {
|
|
184
|
+
const after = await observe(filename);
|
|
185
|
+
if (!after || Boolean(before.absent) !== Boolean(after.absent) || before.mode !== after.mode || (before.bytes && !before.bytes.equals(after.bytes || Buffer.alloc(0)))) throw Object.assign(new Error('项目配置读取期间变化;请刷新。'), { code: 'BUSY', exitCode: 1 });
|
|
186
|
+
}
|
|
187
|
+
for (const [folder, before] of directories) {
|
|
188
|
+
let after = null;
|
|
189
|
+
try { const meta = await lstat(folder); if (!meta.isDirectory() || meta.isSymbolicLink()) throw new Error(); after = (await readdir(folder, { withFileTypes: true })).map(e => [e.name, e.isSymbolicLink() ? 'symlink' : e.isDirectory() ? 'directory' : 'file']).sort(); }
|
|
190
|
+
catch (error) { if (before !== null && error.code !== 'ENOENT') throw Object.assign(new Error('项目角色读取期间变化;请刷新。'), { code: 'BUSY', exitCode: 1 }); }
|
|
191
|
+
if (JSON.stringify(after) !== JSON.stringify(before)) throw Object.assign(new Error('项目角色目录读取期间变化;请刷新。'), { code: 'BUSY', exitCode: 1 });
|
|
192
|
+
}
|
|
193
|
+
if (candidates.length) diagnostics.push({ code: 'PROJECT_OVERRIDE_CANDIDATES', status: 'warn', source: path.resolve(project), message: '项目配置可能覆盖用户级设置;实际生效顺序需要宿主核验。' });
|
|
194
|
+
return { candidates, checked, skipped, diagnostics };
|
|
195
|
+
}
|