@ours.network/fleet 2.3.0 → 2.3.1
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 -1
- package/dist/application/harness-capabilities.d.ts +60 -0
- package/dist/application/harness-capabilities.js +96 -0
- package/dist/application/model-catalog.d.ts +17 -0
- package/dist/application/model-catalog.js +32 -9
- package/dist/application/preset-provenance.d.ts +1 -1
- package/dist/application/preset-provenance.js +18 -3
- package/dist/application/role-creation-service.d.ts +3 -1
- package/dist/application/role-creation-service.js +4 -1
- package/dist/application/task-room-service.d.ts +1 -1
- package/dist/application/task-room-service.js +2 -2
- package/dist/build-info.json +4 -4
- package/dist/harness/claude-code.js +7 -0
- package/dist/harness/codex.js +7 -0
- package/dist/harness/hermes.js +2 -0
- package/dist/harness/types.d.ts +20 -0
- package/dist/init-wizard.d.ts +19 -12
- package/dist/init-wizard.js +122 -104
- package/dist/rooms-tasks/add-member.d.ts +15 -1
- package/dist/rooms-tasks/add-member.js +27 -4
- package/dist/web/server.js +55 -24
- package/package.json +1 -1
- package/presets/README.md +38 -18
- package/presets/brain-catalog.json +19 -19
- package/presets/legacy/generated-brains-rev4.json +497 -0
- package/presets/manifest.json +1 -1
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-high.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-low.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-max.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-medium.yaml +0 -5
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +0 -5
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +0 -4
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-high.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-low.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-max.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-medium.yaml +0 -5
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +0 -5
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +0 -5
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +0 -5
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +0 -5
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +0 -5
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +0 -5
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +0 -5
package/dist/init-wizard.js
CHANGED
|
@@ -4,58 +4,25 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
4
4
|
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
5
5
|
import { loadConfig, splitRootFor } from './config.js';
|
|
6
6
|
import { packagedPresetRoot } from './preset-bootstrap.js';
|
|
7
|
+
import { packagedModelCatalog } from './application/model-catalog.js';
|
|
8
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
9
|
import { listTemplates } from './rooms-tasks/templates.js';
|
|
8
10
|
const WORK_KINDS = ['development', 'review', 'coordination'];
|
|
9
11
|
const ROLE_WORK = {
|
|
10
12
|
Developer: 'development', Engineer: 'development', Critic: 'review',
|
|
11
13
|
LocalCoordinator: 'coordination', Coordinator: 'coordination', FleetCoordinator: 'coordination',
|
|
12
14
|
};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/** The three editable purpose Brains setup creates, by kind of work. */
|
|
16
|
+
export const PURPOSE_BRAINS = {
|
|
17
|
+
development: { id: 'development', label: 'Development' },
|
|
18
|
+
review: { id: 'critic', label: 'Critic' },
|
|
19
|
+
coordination: { id: 'coordination', label: 'Coordination' },
|
|
15
20
|
};
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'gpt-6-luna': 'GPT-6 Luna',
|
|
20
|
-
'claude-fable-5-1': 'Claude Fable 5.1',
|
|
21
|
-
'claude-opus-5-5': 'Claude Opus 5.5',
|
|
22
|
-
'claude-haiku-4-5-20251001': 'Claude Haiku 4.5',
|
|
23
|
-
'gpt-6-astra': 'GPT-6 Astra',
|
|
24
|
-
'gpt-5.6-sol': 'GPT-5.6 Sol',
|
|
25
|
-
'gpt-5.6-terra': 'GPT-5.6 Terra',
|
|
26
|
-
'gpt-5.6-luna': 'GPT-5.6 Luna',
|
|
27
|
-
'gpt-5.5': 'GPT-5.5',
|
|
28
|
-
'gpt-5.4': 'GPT-5.4',
|
|
29
|
-
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
|
30
|
-
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
|
|
31
|
-
'claude-fable-5': 'Claude Fable 5',
|
|
32
|
-
'claude-opus-5': 'Claude Opus 5',
|
|
33
|
-
'claude-sonnet-5': 'Claude Sonnet 5',
|
|
34
|
-
};
|
|
35
|
-
export function validateCatalog(value, path = 'supported model catalog') {
|
|
36
|
-
if (value.schema_version !== 1 || !Array.isArray(value.models) || value.models.length === 0)
|
|
37
|
-
throw new Error(`invalid supported model catalog: ${path}`);
|
|
38
|
-
const tuples = new Set();
|
|
39
|
-
for (const model of value.models) {
|
|
40
|
-
if (!['codex', 'claude-code'].includes(model.harness) || model.session !== 'acp'
|
|
41
|
-
|| typeof model.model !== 'string' || !MODEL_LABELS[model.model]
|
|
42
|
-
|| !Array.isArray(model.efforts)
|
|
43
|
-
|| new Set(model.efforts).size !== model.efforts.length
|
|
44
|
-
|| model.efforts.some(effort => !['low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(effort))
|
|
45
|
-
|| (model.efforts.length > 0 && !['low', 'medium', 'high'].every(effort => model.efforts.includes(effort))))
|
|
46
|
-
throw new Error(`unsupported model state in ${path}`);
|
|
47
|
-
const tuple = `${model.harness}\0${model.session}\0${model.model}`;
|
|
48
|
-
if (tuples.has(tuple))
|
|
49
|
-
throw new Error(`duplicate supported model in ${path}: ${model.model}`);
|
|
50
|
-
tuples.add(tuple);
|
|
51
|
-
}
|
|
52
|
-
return value;
|
|
53
|
-
}
|
|
54
|
-
function catalog() {
|
|
55
|
-
const path = join(packagedPresetRoot(), 'brain-catalog.json');
|
|
56
|
-
return validateCatalog(JSON.parse(readFileSync(path, 'utf8')), path);
|
|
57
|
-
}
|
|
21
|
+
// Legacy --settings files chose one reasoning level for every purpose.
|
|
22
|
+
const LEGACY_REASONING = { quick: 'low', balanced: 'medium', thorough: 'high' };
|
|
23
|
+
const labelOf = (choice) => packagedModelCatalog().find(model => model.harness === choice.harness && model.model === choice.model)?.label ?? choice.model;
|
|
58
24
|
const hasExactKeys = (value, keys) => Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
|
|
25
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
59
26
|
/** Read and validate the complete public InitAnswers JSON shape without side effects. */
|
|
60
27
|
export function readInitSettings(path) {
|
|
61
28
|
let value;
|
|
@@ -65,21 +32,38 @@ export function readInitSettings(path) {
|
|
|
65
32
|
catch (error) {
|
|
66
33
|
throw new Error(`cannot read valid Fleet init settings ${path}: ${error.message}`, { cause: error });
|
|
67
34
|
}
|
|
68
|
-
if (!
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
35
|
+
if (!isObject(value))
|
|
36
|
+
throw new Error('Fleet init settings must be a JSON object');
|
|
37
|
+
const legacy = hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning']);
|
|
38
|
+
if (!legacy && !hasExactKeys(value, ['subscriptions', 'assignmentStrategy', 'models']))
|
|
39
|
+
throw new Error('Fleet init settings must contain exactly subscriptions, assignmentStrategy, and models');
|
|
40
|
+
const models = value.models;
|
|
41
|
+
if (!isObject(models) || !hasExactKeys(models, WORK_KINDS))
|
|
75
42
|
throw new Error('Fleet init settings models must contain exactly development, review, and coordination');
|
|
43
|
+
const chosen = {};
|
|
76
44
|
for (const work of WORK_KINDS) {
|
|
77
45
|
const model = models[work];
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
46
|
+
if (legacy) {
|
|
47
|
+
// The earlier shape: catalog entries plus one reasoning level; each purpose gets that exact effort.
|
|
48
|
+
if (!isObject(model) || !hasExactKeys(model, ['harness', 'session', 'model', 'efforts']))
|
|
49
|
+
throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and efforts`);
|
|
50
|
+
const effort = LEGACY_REASONING[String(value.reasoning)];
|
|
51
|
+
if (!effort)
|
|
52
|
+
throw new Error(`unsupported reasoning preference: ${String(value.reasoning)}`);
|
|
53
|
+
chosen[work] = { harness: model.harness, session: model.session,
|
|
54
|
+
model: model.model, effort };
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (!isObject(model) || !hasExactKeys(model, ['harness', 'session', 'model', 'effort']))
|
|
58
|
+
throw new Error(`Fleet init settings ${work} model must contain exactly harness, session, model, and effort`);
|
|
59
|
+
chosen[work] = model;
|
|
60
|
+
}
|
|
81
61
|
}
|
|
82
|
-
const answers =
|
|
62
|
+
const answers = {
|
|
63
|
+
subscriptions: value.subscriptions,
|
|
64
|
+
assignmentStrategy: value.assignmentStrategy,
|
|
65
|
+
models: chosen,
|
|
66
|
+
};
|
|
83
67
|
// Generation is the single semantic/catalog validator for wizard and file input.
|
|
84
68
|
generateSetup(answers);
|
|
85
69
|
return answers;
|
|
@@ -96,9 +80,7 @@ export async function askInitQuestions(prompter, configuration) {
|
|
|
96
80
|
return undefined;
|
|
97
81
|
if (subscriptions.length === 0)
|
|
98
82
|
throw new Error('Select at least one subscription.');
|
|
99
|
-
|
|
100
|
-
const available = catalog().models.filter(model => subscriptions.includes(subscriptionFor(model))
|
|
101
|
-
&& model.efforts.length > 0);
|
|
83
|
+
const available = packagedModelCatalog().filter(model => subscriptions.includes(subscriptionFor(model)));
|
|
102
84
|
if (!available.length)
|
|
103
85
|
throw new Error('None of the selected subscriptions has a supported model.');
|
|
104
86
|
prompter.note([
|
|
@@ -112,35 +94,41 @@ export async function askInitQuestions(prompter, configuration) {
|
|
|
112
94
|
if (!assignmentStrategy)
|
|
113
95
|
return undefined;
|
|
114
96
|
const choices = available.map(model => ({
|
|
115
|
-
label: `${
|
|
97
|
+
label: `${model.label} (${subscriptionFor(model) === 'codex' ? 'Codex' : 'Claude'}) — ${model.model}; supported catalog ID, not a recommendation`,
|
|
116
98
|
value: model,
|
|
117
99
|
}));
|
|
100
|
+
const askEffort = async (purpose, model) => {
|
|
101
|
+
if (!model.efforts.length) {
|
|
102
|
+
prompter.note(`${model.label} has no effort setting; the ${purpose} Brain sets none.`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const initial = Math.max(0, model.efforts.indexOf('medium'));
|
|
106
|
+
return prompter.select(`Which effort should the ${purpose} Brain use with ${model.label}?`, model.efforts.map(effort => ({ label: effort, value: effort })), initial);
|
|
107
|
+
};
|
|
118
108
|
const selected = {};
|
|
119
109
|
if (assignmentStrategy === 'one-model') {
|
|
120
110
|
const model = await prompter.select('Which model should handle every job?', choices, 0);
|
|
121
111
|
if (!model)
|
|
122
112
|
return undefined;
|
|
123
|
-
for (const work of WORK_KINDS)
|
|
124
|
-
|
|
113
|
+
for (const work of WORK_KINDS) {
|
|
114
|
+
const effort = await askEffort(PURPOSE_BRAINS[work].label, model);
|
|
115
|
+
if (effort === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
selected[work] = { harness: model.harness, session: model.session, model: model.model, effort };
|
|
118
|
+
}
|
|
125
119
|
}
|
|
126
120
|
else {
|
|
127
121
|
for (const work of WORK_KINDS) {
|
|
128
|
-
const model = await prompter.select(`Which model should
|
|
122
|
+
const model = await prompter.select(`Which model should the ${PURPOSE_BRAINS[work].label} Brain use?`, choices, 0);
|
|
129
123
|
if (!model)
|
|
130
124
|
return undefined;
|
|
131
|
-
|
|
125
|
+
const effort = await askEffort(PURPOSE_BRAINS[work].label, model);
|
|
126
|
+
if (effort === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
selected[work] = { harness: model.harness, session: model.session, model: model.model, effort };
|
|
132
129
|
}
|
|
133
130
|
}
|
|
134
|
-
const
|
|
135
|
-
{ label: 'Quick — favor speed', value: 'quick' },
|
|
136
|
-
{ label: 'Balanced — everyday work', value: 'balanced' },
|
|
137
|
-
{ label: 'Thorough — harder work', value: 'thorough' },
|
|
138
|
-
], 1);
|
|
139
|
-
if (!reasoning)
|
|
140
|
-
return undefined;
|
|
141
|
-
const answers = {
|
|
142
|
-
subscriptions: [...subscriptions].sort(), assignmentStrategy, models: selected, reasoning,
|
|
143
|
-
};
|
|
131
|
+
const answers = { subscriptions: [...subscriptions].sort(), assignmentStrategy, models: selected };
|
|
144
132
|
const summary = formatSetupSummary(answers, configuration);
|
|
145
133
|
const finalized = await prompter.confirm('Create this default Fleet setup?', `${summary}\n\nNo changes have been made yet.`);
|
|
146
134
|
return finalized ? answers : undefined;
|
|
@@ -153,18 +141,17 @@ export function formatSetupSummary(answers, configuration) {
|
|
|
153
141
|
const existing = configExists && rootExists ? 'both targets exist'
|
|
154
142
|
: configExists ? 'only the manifest exists'
|
|
155
143
|
: rootExists ? 'only the split configuration directory exists' : 'neither target exists';
|
|
156
|
-
const friendly = (work) =>
|
|
157
|
-
|
|
144
|
+
const friendly = (work) => {
|
|
145
|
+
const choice = answers.models[work];
|
|
146
|
+
return `${PURPOSE_BRAINS[work].label} Brain (${PURPOSE_BRAINS[work].id}): ${labelOf(choice)} (${subscriptionFor(choice) === 'codex' ? 'Codex' : 'Claude'}, ${choice.model}), effort ${choice.effort ?? 'none (model has no effort setting)'}`;
|
|
147
|
+
};
|
|
158
148
|
return [
|
|
159
149
|
'Your default Fleet setup:',
|
|
160
150
|
` Manifest: ${configPath}`,
|
|
161
151
|
` Split configuration: ${splitRoot}`,
|
|
162
152
|
` Existing state: ${existing}`,
|
|
163
153
|
` Model assignment: ${answers.assignmentStrategy === 'one-model' ? 'one model for every job' : 'different models by job'}`,
|
|
164
|
-
`
|
|
165
|
-
` Review: ${friendly('review')}`,
|
|
166
|
-
` Coordination: ${friendly('coordination')}`,
|
|
167
|
-
` Reasoning: ${answers.reasoning[0].toUpperCase()}${answers.reasoning.slice(1)} -> ${effort}`,
|
|
154
|
+
...WORK_KINDS.map(work => ` ${friendly(work)}`),
|
|
168
155
|
' Availability: catalog support is not a recommendation or entitlement claim',
|
|
169
156
|
' Entitlement check: run ours-fleet doctor for Codex; Claude is validated when a role launches',
|
|
170
157
|
' Automatic model fallback: none (no model_chain is generated)',
|
|
@@ -172,8 +159,8 @@ export function formatSetupSummary(answers, configuration) {
|
|
|
172
159
|
' Reviewed pair: Developer + independent Critic',
|
|
173
160
|
' Team: LocalCoordinator + Developer + Critic',
|
|
174
161
|
' Gated engineering: Engineer (gate pipeline) + independent Critic',
|
|
175
|
-
' FleetCoordinator:
|
|
176
|
-
' Brains:
|
|
162
|
+
' FleetCoordinator: Coordination Brain',
|
|
163
|
+
' Brains: three editable purpose Brains (development, critic, coordination); change them or add more in Settings > Brains',
|
|
177
164
|
'',
|
|
178
165
|
'After the final Yes, Fleet performs host service setup before publishing the complete configuration.',
|
|
179
166
|
'A hard termination after that Yes can leave host integration or private stage/recovery evidence to inspect.',
|
|
@@ -182,23 +169,26 @@ export function formatSetupSummary(answers, configuration) {
|
|
|
182
169
|
function preset(relative) {
|
|
183
170
|
return readFileSync(join(packagedPresetRoot(), 'fleet', relative), 'utf8');
|
|
184
171
|
}
|
|
185
|
-
/** The
|
|
186
|
-
export function
|
|
187
|
-
return
|
|
188
|
-
.
|
|
172
|
+
/** The YAML of one purpose Brain holding exactly the chosen triple. */
|
|
173
|
+
export function purposeBrainYaml(work, choice) {
|
|
174
|
+
return [
|
|
175
|
+
`# ${PURPOSE_BRAINS[work].label} Brain created by Fleet setup. Edit it in Settings > Brains.`,
|
|
176
|
+
`harness: ${choice.harness}`,
|
|
177
|
+
`session: ${choice.session}`,
|
|
178
|
+
`model: ${choice.model}`,
|
|
179
|
+
...(choice.effort === null ? [] : [`effort: ${choice.effort}`]),
|
|
180
|
+
'',
|
|
181
|
+
].join('\n');
|
|
189
182
|
}
|
|
190
183
|
/** Build the exact, deterministic default experience without writing to disk. */
|
|
191
184
|
export function generateSetup(answers) {
|
|
192
|
-
const effort = REASONING_EFFORT[answers.reasoning];
|
|
193
|
-
if (!effort)
|
|
194
|
-
throw new Error(`unsupported reasoning preference: ${String(answers.reasoning)}`);
|
|
195
185
|
if (!['one-model', 'per-job'].includes(answers.assignmentStrategy))
|
|
196
186
|
throw new Error(`unsupported model assignment strategy: ${String(answers.assignmentStrategy)}`);
|
|
197
187
|
if (!Array.isArray(answers.subscriptions) || answers.subscriptions.length === 0
|
|
198
188
|
|| new Set(answers.subscriptions).size !== answers.subscriptions.length
|
|
199
189
|
|| answers.subscriptions.some(subscription => !['codex', 'claude'].includes(subscription)))
|
|
200
190
|
throw new Error('subscriptions must be a non-empty unique selection of Codex and/or Claude');
|
|
201
|
-
const supported =
|
|
191
|
+
const supported = packagedModelCatalog();
|
|
202
192
|
const files = new Map();
|
|
203
193
|
files.set('fleet.yaml', [
|
|
204
194
|
'api_version: ours.network/fleet/v2',
|
|
@@ -207,9 +197,8 @@ export function generateSetup(answers) {
|
|
|
207
197
|
' permissions: { approval: ask, filesystem: workspace, unattended: deny }',
|
|
208
198
|
'',
|
|
209
199
|
].join('\n'));
|
|
210
|
-
const assigned = {};
|
|
211
200
|
for (const work of WORK_KINDS) {
|
|
212
|
-
const proposed = answers.models[work];
|
|
201
|
+
const proposed = answers.models?.[work];
|
|
213
202
|
if (!proposed || !answers.subscriptions.includes(subscriptionFor(proposed)))
|
|
214
203
|
throw new Error(`${work} uses a model outside the selected subscriptions`);
|
|
215
204
|
const matches = supported.filter(model => model.harness === proposed.harness
|
|
@@ -217,18 +206,16 @@ export function generateSetup(answers) {
|
|
|
217
206
|
if (matches.length !== 1)
|
|
218
207
|
throw new Error(`${work} uses an unknown or ambiguous supported model`);
|
|
219
208
|
const model = matches[0];
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
209
|
+
// The chosen effort is kept exactly; nothing is substituted.
|
|
210
|
+
if (model.efforts.length === 0) {
|
|
211
|
+
if (proposed.effort !== null)
|
|
212
|
+
throw new Error(`${model.label} has no effort setting; ${work} effort must be null`);
|
|
213
|
+
}
|
|
214
|
+
else if (typeof proposed.effort !== 'string' || !model.efforts.includes(proposed.effort)) {
|
|
215
|
+
throw new Error(`${model.label} does not support effort '${String(proposed.effort)}' for ${work}; choose one of: ${model.efforts.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
files.set(`brains/${PURPOSE_BRAINS[work].id}.yaml`, purposeBrainYaml(work, proposed));
|
|
229
218
|
}
|
|
230
|
-
for (const name of readdirSync(join(packagedPresetRoot(), 'fleet', 'brains')).filter(name => name.endsWith('.yaml')).sort())
|
|
231
|
-
files.set(`brains/${name}`, preset(`brains/${name}`));
|
|
232
219
|
if (answers.assignmentStrategy === 'one-model') {
|
|
233
220
|
const tuples = new Set(WORK_KINDS.map(work => {
|
|
234
221
|
const model = answers.models[work];
|
|
@@ -243,7 +230,7 @@ export function generateSetup(answers) {
|
|
|
243
230
|
continue;
|
|
244
231
|
files.set(`agent_templates/${role}.yaml`, [
|
|
245
232
|
`role: { ref: ${role} }`,
|
|
246
|
-
`brain: { ref: ${
|
|
233
|
+
`brain: { ref: ${PURPOSE_BRAINS[ROLE_WORK[role]].id} }`,
|
|
247
234
|
'coordinator: FleetCoordinator',
|
|
248
235
|
// Temporary default while the product is being tested: prepared agents work without asking.
|
|
249
236
|
'permissions: { approval: allow, filesystem: unrestricted, unattended: deny }',
|
|
@@ -252,13 +239,40 @@ export function generateSetup(answers) {
|
|
|
252
239
|
].join('\n'));
|
|
253
240
|
}
|
|
254
241
|
files.set('agents/FleetCoordinator.yaml', preset('agents/FleetCoordinator.yaml')
|
|
255
|
-
.replace('brain: { ref: claude-default }', `brain: { ref: ${
|
|
242
|
+
.replace('brain: { ref: claude-default }', `brain: { ref: ${PURPOSE_BRAINS.coordination.id} }`));
|
|
256
243
|
for (const name of ['single', 'pair', 'team', 'engineering']) {
|
|
257
244
|
files.set(`room_templates/${name}.yaml`, preset(`room_templates/${name}.yaml`));
|
|
258
245
|
files.set(`room_layouts/${name}.yaml`, preset(`room_layouts/${name}.yaml`));
|
|
259
246
|
}
|
|
260
247
|
return { files, answers };
|
|
261
248
|
}
|
|
249
|
+
/** Setup stopped because an existing purpose Brain holds other settings than the chosen ones. */
|
|
250
|
+
export class PurposeBrainConflictError extends Error {
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Setup preserves existing files. A purpose Brain that already exists with a different
|
|
254
|
+
* harness, session, model or effort is never overwritten; setup stops and says so.
|
|
255
|
+
*/
|
|
256
|
+
export function purposeBrainConflicts(configuration, answers) {
|
|
257
|
+
const brains = join(splitRootFor(resolve(configuration)), 'brains');
|
|
258
|
+
return WORK_KINDS.flatMap(work => {
|
|
259
|
+
const { id } = PURPOSE_BRAINS[work];
|
|
260
|
+
const path = join(brains, `${id}.yaml`);
|
|
261
|
+
if (!existsSync(path))
|
|
262
|
+
return [];
|
|
263
|
+
let value;
|
|
264
|
+
try {
|
|
265
|
+
value = (parseYaml(readFileSync(path, 'utf8')) ?? {});
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [`brains/${id}.yaml exists and cannot be read`];
|
|
269
|
+
}
|
|
270
|
+
const wanted = answers.models[work];
|
|
271
|
+
const same = value.harness === wanted.harness && (value.session ?? 'acp') === wanted.session
|
|
272
|
+
&& value.model === wanted.model && (value.effort ?? null) === wanted.effort;
|
|
273
|
+
return same ? [] : [`brains/${id}.yaml already exists with different settings`];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
262
276
|
/** Run the mutation boundary only after the complete questionnaire is accepted. */
|
|
263
277
|
export async function executeInitWizard(prompter, configuration, deps) {
|
|
264
278
|
const answers = await askInitQuestions(prompter, configuration);
|
|
@@ -273,6 +287,10 @@ export async function executeInitAnswers(answers, configuration, deps) {
|
|
|
273
287
|
// deterministically in tests.
|
|
274
288
|
const setup = (deps.generate ?? generateSetup)(answers);
|
|
275
289
|
(deps.preflight ?? preflightInitPaths)(configuration);
|
|
290
|
+
const conflicts = purposeBrainConflicts(configuration, answers);
|
|
291
|
+
if (conflicts.length)
|
|
292
|
+
throw new PurposeBrainConflictError(`Setup would not use the chosen models: ${conflicts.join('; ')}. `
|
|
293
|
+
+ 'Existing Brains are kept; change them in Settings > Brains or choose matching models.');
|
|
276
294
|
try {
|
|
277
295
|
await deps.hostSetup();
|
|
278
296
|
}
|
|
@@ -6,13 +6,25 @@ import type { TaskLayouts } from '../application/task-layouts.js';
|
|
|
6
6
|
export declare class MemberAdditionRejected extends TaskStateError {
|
|
7
7
|
readonly accepted = false;
|
|
8
8
|
}
|
|
9
|
+
/** A one-off harness/model/effort for this member only; no Brain is saved. Omitted model/effort keep the harness default. */
|
|
10
|
+
export interface ManualBrain {
|
|
11
|
+
harness: string;
|
|
12
|
+
session?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
effort?: string;
|
|
15
|
+
}
|
|
16
|
+
/** `brain` omitted: the member uses its Agent Template's own Brain, unchanged. */
|
|
9
17
|
export interface AddMemberRequest {
|
|
10
18
|
requestId: string;
|
|
11
19
|
slot: string;
|
|
12
20
|
role: string;
|
|
13
|
-
brain
|
|
21
|
+
brain?: string | {
|
|
22
|
+
inline: ManualBrain;
|
|
23
|
+
};
|
|
14
24
|
agentTemplate: string;
|
|
15
25
|
}
|
|
26
|
+
/** No Brain, a saved Brain name, or exactly one manual harness/model/effort. */
|
|
27
|
+
export declare function validMemberBrain(brain: unknown): brain is AddMemberRequest['brain'];
|
|
16
28
|
export declare function memberAddition(taskId: string, requestId: string): {
|
|
17
29
|
requestId: string;
|
|
18
30
|
taskId: string;
|
|
@@ -35,6 +47,8 @@ export declare function requestMemberAddition(input: {
|
|
|
35
47
|
binPath: string;
|
|
36
48
|
provision?: typeof provisionMembers;
|
|
37
49
|
layouts?: TaskLayouts;
|
|
50
|
+
/** Checks a new request only; a retry of an accepted request returns its receipt. */
|
|
51
|
+
checkNewRequest?: (request: AddMemberRequest) => Promise<void>;
|
|
38
52
|
}): Promise<{
|
|
39
53
|
requestId: string;
|
|
40
54
|
taskId: string;
|
|
@@ -16,6 +16,28 @@ import { validLayoutKey } from './layout-config.js';
|
|
|
16
16
|
export class MemberAdditionRejected extends TaskStateError {
|
|
17
17
|
accepted = false;
|
|
18
18
|
}
|
|
19
|
+
const MANUAL_KEYS = ['harness', 'session', 'model', 'effort'];
|
|
20
|
+
const text = (v, max = 128) => typeof v === 'string' && v.length > 0 && v.length <= max;
|
|
21
|
+
/** No Brain, a saved Brain name, or exactly one manual harness/model/effort. */
|
|
22
|
+
export function validMemberBrain(brain) {
|
|
23
|
+
if (brain === undefined || text(brain))
|
|
24
|
+
return true;
|
|
25
|
+
const inline = brain?.inline;
|
|
26
|
+
return Boolean(brain) && typeof brain === 'object' && Object.keys(brain).join() === 'inline'
|
|
27
|
+
&& Boolean(inline) && typeof inline === 'object' && !Array.isArray(inline)
|
|
28
|
+
&& Object.keys(inline).every(key => MANUAL_KEYS.includes(key)) && text(inline.harness)
|
|
29
|
+
&& ['session', 'model', 'effort'].every(key => inline[key] === undefined || text(inline[key]));
|
|
30
|
+
}
|
|
31
|
+
/** The member override: a saved Brain by name, or the manual triple as this member's own inline Brain. */
|
|
32
|
+
function brainOverride(brain) {
|
|
33
|
+
if (brain === undefined)
|
|
34
|
+
return {};
|
|
35
|
+
if (typeof brain === 'string')
|
|
36
|
+
return { brain };
|
|
37
|
+
const { harness, session, model, effort } = brain.inline;
|
|
38
|
+
return { overrides: { brain: { inline: { harness, session: session ?? 'acp',
|
|
39
|
+
...(model !== undefined ? { model } : {}), ...(effort !== undefined ? { effort } : {}) } } } };
|
|
40
|
+
}
|
|
19
41
|
const running = new Set();
|
|
20
42
|
function pathFor(taskId, requestId) {
|
|
21
43
|
if (!/^[a-z0-9]{10,40}$/.test(taskId) || !/^[a-zA-Z0-9_-]{8,80}$/.test(requestId))
|
|
@@ -34,9 +56,9 @@ export function memberAddition(taskId, requestId) {
|
|
|
34
56
|
export async function requestMemberAddition(input) {
|
|
35
57
|
const { request: r, taskId } = input;
|
|
36
58
|
if (!r || typeof r.slot !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(r.slot)
|
|
37
|
-
|| ![r.role, r.
|
|
59
|
+
|| ![r.role, r.agentTemplate].every(v => text(v)) || !validMemberBrain(r.brain))
|
|
38
60
|
// No receipt lookup has happened yet: this request ID may already be accepted.
|
|
39
|
-
throw new TaskStateError('Slot, Role
|
|
61
|
+
throw new TaskStateError('Slot, Role and Agent Template are required; Brain must be a saved Brain or a harness, model and effort');
|
|
40
62
|
const path = pathFor(taskId, r.requestId);
|
|
41
63
|
const hash = createHash('sha256').update(canonicalJson(r)).digest('hex');
|
|
42
64
|
mkdirSync(join(stateRoot(), 'member-additions', taskId), { recursive: true, mode: 0o700 });
|
|
@@ -47,6 +69,7 @@ export async function requestMemberAddition(input) {
|
|
|
47
69
|
throw new TaskStateError('Request ID was already used with different settings');
|
|
48
70
|
return memberAddition(taskId, r.requestId);
|
|
49
71
|
}
|
|
72
|
+
await input.checkNewRequest?.(r);
|
|
50
73
|
const task = getTask(taskId);
|
|
51
74
|
const room = task.room_id && getRoomRecord(task.room_id);
|
|
52
75
|
if (task.layout?.name === EMPTY_TASK_LAYOUT && input.layouts) {
|
|
@@ -63,7 +86,7 @@ export async function requestMemberAddition(input) {
|
|
|
63
86
|
const definition = (() => {
|
|
64
87
|
try {
|
|
65
88
|
const plan = prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
|
|
66
|
-
members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role,
|
|
89
|
+
members: [{ slot: r.slot, role: r.slot, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, ...brainOverride(r.brain), ...(task.workspace ? { cwd: task.workspace.path } : {}) } });
|
|
67
90
|
return Object.values(plan.launchDefinitions)[0];
|
|
68
91
|
}
|
|
69
92
|
catch (error) {
|
|
@@ -106,7 +129,7 @@ export async function requestMemberAddition(input) {
|
|
|
106
129
|
const plan = (() => {
|
|
107
130
|
try {
|
|
108
131
|
return prepareExecutionPlan({ name: 'additional-member', version: 1, description: 'Additional task participant',
|
|
109
|
-
contract: room.template_snapshot?.contract, members: [{ slot: r.slot, role: r.role, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role,
|
|
132
|
+
contract: room.template_snapshot?.contract, members: [{ slot: r.slot, role: r.role, count: 1, agent_template: r.agentTemplate }] }, input.cfg, { [r.slot]: { role: r.role, ...brainOverride(r.brain) } });
|
|
110
133
|
}
|
|
111
134
|
catch (error) {
|
|
112
135
|
throw new MemberAdditionRejected(error instanceof Error ? error.message : 'Invalid member configuration');
|
package/dist/web/server.js
CHANGED
|
@@ -7,15 +7,13 @@ import { registerConversationStream } from './conversation-stream.js';
|
|
|
7
7
|
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
8
8
|
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
9
9
|
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { validateCatalog } from '../init-wizard.js';
|
|
10
|
+
import { PURPOSE_BRAINS, PurposeBrainConflictError } from '../init-wizard.js';
|
|
11
|
+
import { harnessCapabilities, validateBrainChoice } from '../application/harness-capabilities.js';
|
|
13
12
|
import { getAdapter } from '../harness/registry.js';
|
|
14
13
|
import { storeAgentAttachment, attachmentPrompt, MAX_ATTACHMENT_BODY, readAgentAttachment, prepareAttachmentPresentation, presentAttachmentEvent } from './agent-attachments.js';
|
|
15
14
|
import { readChatIdle } from '../temp-idle.js';
|
|
16
15
|
import { agentDir } from '../paths.js';
|
|
17
16
|
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
17
|
import Fastify from 'fastify';
|
|
20
18
|
import fastifyWebsocket from '@fastify/websocket';
|
|
21
19
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
@@ -215,12 +213,12 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
215
213
|
return { harness, ok: false, checks: [{ name: 'availability', ok: false, detail: 'Harness checks unavailable' }] };
|
|
216
214
|
}
|
|
217
215
|
}));
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
const payload = { harnesses, catalog
|
|
216
|
+
// The catalog is each adapter's maintained models, narrowed to what the active Codex account offers.
|
|
217
|
+
const capabilities = await harnessCapabilities({ harnesses: ['codex', 'claude-code'], codexModels: services.codexModels, checkReadiness: false });
|
|
218
|
+
const codex = capabilities.harnesses.find(item => item.harness === 'codex');
|
|
219
|
+
const catalog = capabilities.harnesses.flatMap(item => item.models.filter(model => model.available && model.efforts !== null)
|
|
220
|
+
.map(model => ({ harness: item.harness, session: 'acp', model: model.id, label: model.label, efforts: model.efforts })));
|
|
221
|
+
const payload = { harnesses, catalog, ...(codex?.discovery ? { codexModels: codex.discovery } : {}), providers: services.subscriptions ? await services.subscriptions.list() : [], coordinator: 'FleetCoordinator', configured: onboardingConfigured() };
|
|
224
222
|
if (revision === services.configuration?.read().revision) {
|
|
225
223
|
onboardingObserved = { owner: browser.id, revision, payload };
|
|
226
224
|
events.publish('onboarding.changed', {});
|
|
@@ -245,15 +243,25 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
245
243
|
if (!services.configuration || !services.onboardingSetup)
|
|
246
244
|
throw new FleetError('capability_unavailable', 'first-time Fleet setup is unavailable');
|
|
247
245
|
const requested = request.body?.models;
|
|
248
|
-
const
|
|
249
|
-
const supported = validateCatalog(JSON.parse(readFileSync(join(packagedPresetRoot(), 'brain-catalog.json'), 'utf8'))).models
|
|
250
|
-
.filter(model => model.harness !== 'codex' || !codexModels || (codexModels.state === 'known' && codexModels.models.includes(model.model)));
|
|
246
|
+
const capabilities = await harnessCapabilities({ harnesses: ['codex', 'claude-code'], codexModels: services.codexModels, checkReadiness: false });
|
|
251
247
|
const models = {};
|
|
252
248
|
for (const work of ONBOARDING_WORK) {
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
249
|
+
const choice = requested?.[work];
|
|
250
|
+
const label = PURPOSE_BRAINS[work].label;
|
|
251
|
+
if (!choice || typeof choice !== 'object' || !('effort' in choice))
|
|
252
|
+
throw new FleetError('invalid_request', `choose a harness, model and effort for the ${label} Brain`);
|
|
253
|
+
const { harness, model, effort } = choice;
|
|
254
|
+
if (typeof harness !== 'string' || typeof model !== 'string' || (effort !== null && typeof effort !== 'string'))
|
|
255
|
+
throw new FleetError('invalid_request', `choose a harness, model and effort for the ${label} Brain`);
|
|
256
|
+
const check = validateBrainChoice(capabilities, { harness, session: 'acp', model, effort });
|
|
257
|
+
// Setup offers the adapters' maintained models only, each with an explicit effort when it has one.
|
|
258
|
+
if (!check.ok)
|
|
259
|
+
throw new FleetError('invalid_request', `${label} Brain: ${check.message}`);
|
|
260
|
+
if (!check.model || check.model.source !== 'adapter')
|
|
261
|
+
throw new FleetError('invalid_request', `${label} Brain: choose a supported model`);
|
|
262
|
+
if (check.model.efforts?.length && effort === null)
|
|
263
|
+
throw new FleetError('invalid_request', `${label} Brain: choose an effort for ${check.model.label} (${check.model.efforts.join(', ')})`);
|
|
264
|
+
models[work] = { harness: harness, session: 'acp', model, effort: effort };
|
|
257
265
|
}
|
|
258
266
|
const chosen = Object.values(models);
|
|
259
267
|
const subscriptions = [...new Set(chosen.map(model => model.harness === 'codex' ? 'codex' : 'claude'))];
|
|
@@ -264,31 +272,49 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
264
272
|
if (onboardingConfigured())
|
|
265
273
|
throw new FleetError('conflict', 'Fleet is already set up; change its models in the configuration');
|
|
266
274
|
try {
|
|
267
|
-
await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models
|
|
275
|
+
await setup({ subscriptions, assignmentStrategy: oneModel ? 'one-model' : 'per-job', models });
|
|
268
276
|
}
|
|
269
277
|
catch (error) {
|
|
278
|
+
if (error instanceof PurposeBrainConflictError)
|
|
279
|
+
throw new FleetError('conflict', error.message);
|
|
270
280
|
throw new FleetError('backend_failure', `Fleet setup did not complete: ${error.message}`);
|
|
271
281
|
}
|
|
272
|
-
// Setup preserves files it finds, so confirm
|
|
273
|
-
// The prepared agents reference packaged Brain presets, so resolve what each of them now points at:
|
|
274
|
-
// a preserved file of a packaged name may hold another model, session or reasoning than its name says.
|
|
282
|
+
// Setup preserves files it finds, so confirm each prepared agent now resolves to exactly the chosen triple.
|
|
275
283
|
const published = configuration.read(true).model, brains = published.brains ?? {};
|
|
276
284
|
const brainOf = (holder) => brains[String(holder?.brain?.ref ?? '')];
|
|
277
285
|
const assigned = { coordination: brainOf(published.agents.FleetCoordinator), development: brainOf(published.agent_templates?.Developer), review: brainOf(published.agent_templates?.Critic) };
|
|
278
286
|
for (const work of ONBOARDING_WORK)
|
|
279
|
-
if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || assigned[work]?.effort !==
|
|
287
|
+
if (assigned[work]?.harness !== models[work].harness || assigned[work]?.model !== models[work].model || assigned[work]?.session !== models[work].session || (assigned[work]?.effort ?? null) !== models[work].effort)
|
|
280
288
|
throw new FleetError('conflict', 'Fleet was set up with different models; review its configuration');
|
|
281
289
|
return { configured: true };
|
|
282
290
|
});
|
|
283
291
|
onboardingSetupQueue = run.catch(() => { });
|
|
284
292
|
return run;
|
|
285
293
|
});
|
|
294
|
+
app.get('/api/v1/harness-capabilities', async (request) => {
|
|
295
|
+
auth.authenticate(request);
|
|
296
|
+
return harnessCapabilities({ codexModels: services.codexModels });
|
|
297
|
+
});
|
|
286
298
|
app.get('/api/v1/creation-capabilities', async (request) => {
|
|
287
299
|
auth.authenticate(request);
|
|
288
300
|
return services.creation.capabilities();
|
|
289
301
|
});
|
|
302
|
+
// A new agent's own harness/model/effort must be one its adapter supports; saved Brains are used as they are.
|
|
303
|
+
const checkBrainChoice = async (brain) => {
|
|
304
|
+
if (!brain || typeof brain !== 'object')
|
|
305
|
+
return;
|
|
306
|
+
const value = brain;
|
|
307
|
+
const check = validateBrainChoice(await harnessCapabilities({ codexModels: services.codexModels, checkReadiness: false }), {
|
|
308
|
+
harness: String(value.harness), session: value.session,
|
|
309
|
+
model: value.model, effort: value.effort
|
|
310
|
+
});
|
|
311
|
+
if (!check.ok)
|
|
312
|
+
throw new FleetError('invalid_request', `Brain: ${check.message}`);
|
|
313
|
+
};
|
|
314
|
+
const checkInlineBrain = (creation) => checkBrainChoice(creation?.brain?.inline);
|
|
290
315
|
app.post('/api/v1/roles/preview', async (request) => {
|
|
291
316
|
auth.authenticate(request, true);
|
|
317
|
+
await checkInlineBrain(request.body);
|
|
292
318
|
return services.creation.preview(request.body);
|
|
293
319
|
});
|
|
294
320
|
app.post('/api/v1/roles', async (request, reply) => {
|
|
@@ -297,7 +323,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
297
323
|
const idempotencyKey = String(request.headers['idempotency-key'] ?? '');
|
|
298
324
|
if (!body?.request || !body.previewHash)
|
|
299
325
|
throw new FleetError('invalid_request', 'request and previewHash are required');
|
|
300
|
-
const action = await services.creation.create(body.request, body.previewHash, idempotencyKey, session.id);
|
|
326
|
+
const action = await services.creation.create(body.request, body.previewHash, idempotencyKey, session.id, checkInlineBrain);
|
|
301
327
|
events.publish('creation.changed', action, action.roleId);
|
|
302
328
|
reply.header('Location', `/api/v1/creation-actions/${encodeURIComponent(action.actionId)}`);
|
|
303
329
|
reply.code(202);
|
|
@@ -702,7 +728,12 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
702
728
|
auth.authenticate(request, true);
|
|
703
729
|
const body = request.body;
|
|
704
730
|
try {
|
|
705
|
-
|
|
731
|
+
// A new manual harness/model/effort must be one the adapter supports; nothing is substituted.
|
|
732
|
+
// A retry of an accepted request returns its receipt even if the account's models changed since.
|
|
733
|
+
const result = await taskApi(() => requireTaskRooms().addMember(taskRef(request), body, async (member) => {
|
|
734
|
+
if (typeof member.brain === 'object')
|
|
735
|
+
await checkBrainChoice(member.brain.inline);
|
|
736
|
+
}));
|
|
706
737
|
reply.code(result.state === 'running' ? 202 : 200);
|
|
707
738
|
return result;
|
|
708
739
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|