Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 44 additions & 61 deletions src/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3462,7 +3462,7 @@ Continue from where you left off. Review your previous output to understand what

// Phase 2: Build mock cluster config with proposed agents
// Collect all agents that would exist after operations complete
const existingAgentConfigs = cluster.config.agents || [];
const existingAgentConfigs = this._prepareExistingAgentConfigsForValidation(cluster);
const proposedAgentConfigs = this._buildProposedAgentConfigs(existingAgentConfigs, operations);

// Phase 3: Validate proposed cluster config
Expand Down Expand Up @@ -3532,16 +3532,22 @@ Continue from where you left off. Review your previous output to understand what
if (op.action === 'add_agents' && op.agents) {
for (const agentConfig of op.agents) {
const existingIdx = proposedAgentConfigs.findIndex((a) => a.id === agentConfig.id);
const proposedAgentConfig = JSON.parse(JSON.stringify(agentConfig));
if (existingIdx === -1) {
proposedAgentConfigs.push(agentConfig);
proposedAgentConfigs.push(proposedAgentConfig);
} else {
proposedAgentConfigs[existingIdx] = proposedAgentConfig;
}
}
} else if (op.action === 'load_config' && op.config) {
const loadedAgentConfigs = this._resolveLoadConfigAgents(op.config);
for (const agentConfig of loadedAgentConfigs) {
const existingIdx = proposedAgentConfigs.findIndex((a) => a.id === agentConfig.id);
const proposedAgentConfig = JSON.parse(JSON.stringify(agentConfig));
if (existingIdx === -1) {
proposedAgentConfigs.push(agentConfig);
proposedAgentConfigs.push(proposedAgentConfig);
} else {
proposedAgentConfigs[existingIdx] = proposedAgentConfig;
}
}
} else if (op.action === 'remove_agents' && op.agentIds) {
Expand All @@ -3562,38 +3568,42 @@ Continue from where you left off. Review your previous output to understand what
return proposedAgentConfigs;
}

_prepareExistingAgentConfigsForValidation(cluster) {
const existingAgentConfigs = JSON.parse(JSON.stringify(cluster?.config?.agents || []));

// The CLI validates --model before startup, then materializes it into every
// initial agent config and persists the override separately on the cluster.
// Only those already-accepted configs have that provenance. Strip their
// runtime model projection before validating a later operation chain; new
// add/load/update payloads stay untouched and must satisfy the authored
// modelLevel-only policy even when their value equals the active override.
if (cluster?.modelOverride) {
for (const agentConfig of existingAgentConfigs) {
delete agentConfig.model;
}
}

return existingAgentConfigs;
}

_resolveLoadConfigAgents(config) {
return this._resolveLoadConfig(config).loadedConfig.agents;
}

_resolveLoadConfig(config) {
if (!config) {
throw new Error('load_config operation missing config');
}

const templatesDir = path.join(__dirname, '..', 'cluster-templates');
let loadedConfig;

// Parameterized template - resolve with TemplateResolver
if (typeof config === 'object' && config.base) {
const { base, params } = config;
const resolver = new TemplateResolver(templatesDir);
loadedConfig = resolver.resolve(base, params || {});
} else if (typeof config === 'string') {
// Static config - load directly from file
const configPath = path.join(templatesDir, `${config}.json`);
if (!fs.existsSync(configPath)) {
throw new Error(`Config not found: ${config} (looked in ${configPath})`);
}
const configContent = fs.readFileSync(configPath, 'utf8');
loadedConfig = JSON.parse(configContent);
} else {
throw new Error(
`Invalid config format: expected string or {base, params}, got ${typeof config}`
);
}
const resolver = new TemplateResolver(templatesDir);
const resolvedConfig = resolver.resolveConfigReference(config);

if (!loadedConfig.agents || !Array.isArray(loadedConfig.agents)) {
if (!resolvedConfig.loadedConfig.agents || !Array.isArray(resolvedConfig.loadedConfig.agents)) {
throw new Error(`Config has no agents array`);
}

return loadedConfig.agents;
return resolvedConfig;
}

_hasCompletionHandler(agentConfigs) {
Expand Down Expand Up @@ -3954,44 +3964,17 @@ Continue from where you left off. Review your previous output to understand what
*/
async _opLoadConfig(cluster, op, context) {
const { config } = op;
if (!config) {
throw new Error('load_config operation missing config');
}
const resolvedConfig = this._resolveLoadConfig(config);
const loadedConfig = resolvedConfig.loadedConfig;

const templatesDir = path.join(__dirname, '..', 'cluster-templates');
let loadedConfig;

// Check if config is parameterized ({ base, params }) or static (string)
if (typeof config === 'object' && config.base) {
// Parameterized template - resolve with TemplateResolver
const { base, params } = config;
this._log(` Loading parameterized template: ${base}`);
this._log(` Params: ${JSON.stringify(params)}`);

const resolver = new TemplateResolver(templatesDir);
loadedConfig = resolver.resolve(base, params);

this._log(` ✓ Resolved template: ${base} → ${loadedConfig.agents?.length || 0} agent(s)`);
} else if (typeof config === 'string') {
// Static config - load directly from file
const configPath = path.join(templatesDir, `${config}.json`);

if (!fs.existsSync(configPath)) {
throw new Error(`Config not found: ${config} (looked in ${configPath})`);
}

this._log(` Loading static config: ${config}`);

const configContent = fs.readFileSync(configPath, 'utf8');
loadedConfig = JSON.parse(configContent);
} else {
throw new Error(
`Invalid config format: expected string or {base, params}, got ${typeof config}`
if (resolvedConfig.kind === 'parameterized') {
this._log(` Loading parameterized template: ${resolvedConfig.name}`);
this._log(` Params: ${JSON.stringify(resolvedConfig.params)}`);
this._log(
` ✓ Resolved template: ${resolvedConfig.name} → ${loadedConfig.agents.length} agent(s)`
);
}

if (!loadedConfig.agents || !Array.isArray(loadedConfig.agents)) {
throw new Error(`Config has no agents array`);
} else {
this._log(` Loading static config: ${resolvedConfig.name}`);
}

this._log(` Found ${loadedConfig.agents.length} agent(s)`);
Expand Down
73 changes: 71 additions & 2 deletions src/template-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,32 @@ const fs = require('fs');
const path = require('path');

const COMPARISON_OPERATORS = ['==', '!=', '<=', '>=', '<', '>'];
const TEMPLATE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;

function invalidTemplateNameError(label, name) {
return new Error(
`Invalid ${label} name ${JSON.stringify(name)}: use only letters, numbers, hyphens, and underscores`
);
}

function resolveNamedTemplatePath(directory, name, label) {
if (typeof name !== 'string' || !TEMPLATE_NAME_PATTERN.test(name)) {
throw invalidTemplateNameError(label, name);
}

const templateRoot = path.resolve(directory);
const templatePath = path.resolve(templateRoot, `${name}.json`);
const relativePath = path.relative(templateRoot, templatePath);
if (
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
throw invalidTemplateNameError(label, name);
}

return templatePath;
}

function isIdentifierChar(char) {
if (!char) return false;
Expand Down Expand Up @@ -97,6 +123,49 @@ class TemplateResolver {
this.baseTemplatesDir = path.join(templatesDir, 'base-templates');
}

/**
* Load a static or parameterized cluster config from a safe named reference.
* Names are identifiers, never filesystem paths.
*
* @param {string | {base: string, params?: Object}} config
* @returns {{kind: 'static' | 'parameterized', name: string, params: Object | null, loadedConfig: Object}}
*/
resolveConfigReference(config) {
if (typeof config === 'string') {
const configPath = resolveNamedTemplatePath(this.templatesDir, config, 'config');
if (!fs.existsSync(configPath)) {
throw new Error(`Config not found: ${config} (looked in ${configPath})`);
}

return {
kind: 'static',
name: config,
params: null,
loadedConfig: JSON.parse(fs.readFileSync(configPath, 'utf8')),
};
}

if (
config &&
typeof config === 'object' &&
!Array.isArray(config) &&
Object.prototype.hasOwnProperty.call(config, 'base')
) {
const { base, params } = config;
const resolvedParams = params || {};
return {
kind: 'parameterized',
name: base,
params: resolvedParams,
loadedConfig: this.resolve(base, resolvedParams),
};
}

throw new Error(
`Invalid config format: expected string or {base, params}, got ${typeof config}`
);
}

/**
* Resolve a template with parameters
* @param {string} baseName - Name of base template (without .json)
Expand All @@ -105,7 +174,7 @@ class TemplateResolver {
*/
resolve(baseName, params) {
// Load base template
const templatePath = path.join(this.baseTemplatesDir, `${baseName}.json`);
const templatePath = resolveNamedTemplatePath(this.baseTemplatesDir, baseName, 'base template');
if (!fs.existsSync(templatePath)) {
throw new Error(`Base template not found: ${baseName} (looked in ${templatePath})`);
}
Expand Down Expand Up @@ -409,7 +478,7 @@ class TemplateResolver {
* @returns {any}
*/
getTemplateInfo(baseName) {
const templatePath = path.join(this.baseTemplatesDir, `${baseName}.json`);
const templatePath = resolveNamedTemplatePath(this.baseTemplatesDir, baseName, 'base template');
if (!fs.existsSync(templatePath)) {
return null;
}
Expand Down
15 changes: 2 additions & 13 deletions src/template-validation/simulate-random-topology.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
const fs = require('node:fs');
const path = require('node:path');

const Ledger = require('../ledger');
const MessageBus = require('../message-bus');
const LogicEngine = require('../logic-engine');
Expand Down Expand Up @@ -227,16 +224,8 @@ function parseOperations(raw) {
}

function resolveConfigOperation({ configOp, templatesDir }) {
if (typeof configOp === 'object' && configOp?.base) {
const resolver = new TemplateResolver(templatesDir);
return resolver.resolve(configOp.base, configOp.params || {});
}
if (typeof configOp === 'string') {
const configPath = path.join(templatesDir, `${configOp}.json`);
const configContent = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configContent);
}
throw new Error(`Unsupported load_config payload: ${JSON.stringify(configOp)}`);
const resolver = new TemplateResolver(templatesDir);
return resolver.resolveConfigReference(configOp).loadedConfig;
}

function applyClusterOperation({ state, messageBus, operation, sourceMessage, templatesDir }) {
Expand Down
72 changes: 72 additions & 0 deletions tests/e2e/cluster-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,76 @@ describe('e2e: cluster lifecycle (daemon mode + observability commands)', functi

fs.rmSync(issueDir, { recursive: true, force: true });
});

it('preserves a CLI model override through detached conductor load_config', async function () {
const issueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-e2e-issue-'));
const issuePath = path.join(issueDir, 'model-override.md');
const scenarioFile = path.join(issueDir, 'conductor-trivial-task.json');
let clusterId = null;
fs.writeFileSync(issuePath, '# Model override\n\nExercise dynamic config loading.\n');
fs.writeFileSync(
scenarioFile,
JSON.stringify({
messages: [
JSON.stringify({
complexity: 'TRIVIAL',
taskType: 'TASK',
reasoning: 'Use the single-worker route.',
}),
],
exitCode: 0,
})
);

try {
const configPath = path.join(
__dirname,
'..',
'..',
'cluster-templates',
'conductor-bootstrap.json'
);
const startResult = runZeroshot(
env,
['run', issuePath, '-d', '--config', configPath, '--model', 'sonnet'],
{
FAKE_AGENT_SCENARIO: scenarioFile,
timeout: 15000,
}
);
assert.strictEqual(
startResult.status,
0,
`zeroshot run -d exited ${startResult.status}\nSTDOUT:\n${startResult.stdout}\nSTDERR:\n${startResult.stderr}`
);

const clusterIdMatch = /Started (\S+)/.exec(startResult.stdout);
assert.ok(clusterIdMatch, `expected "Started <id>" in stdout, got:\n${startResult.stdout}`);
clusterId = clusterIdMatch[1];
const cluster = await pollUntil(() => {
const current = readCluster(env, clusterId);
return current?.state === 'stopped' ? current : null;
}, 30000);

assert.strictEqual(cluster.modelOverride, 'sonnet');
assert.ok(
cluster.config.agents.some((agent) => agent.id === 'worker'),
'detached conductor should load the worker config'
);
for (const agent of cluster.config.agents) {
assert.strictEqual(
agent.model,
'sonnet',
`persisted agent ${agent.id} should retain the CLI model override`
);
}
assert.strictEqual(cluster.failureInfo, null);
} finally {
const cluster = clusterId ? readCluster(env, clusterId) : null;
if (cluster && !['stopped', 'killed'].includes(cluster.state)) {
runZeroshot(env, ['kill', clusterId], { timeout: 15000 });
}
fs.rmSync(issueDir, { recursive: true, force: true });
}
});
});
Loading
Loading