Skip to content
Merged
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
223 changes: 199 additions & 24 deletions src/adapters/mcp-adapter.ts

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions src/adapters/mcp-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ The orchestrator manages its own task graph — you just provide the goal and gu
- RetryTask to re-run a task from scratch
- CancelTask / CancelLoop / CancelOrchestrator to stop work that's going wrong

## Agent & Model Configuration

### Per-task model override
All task-creating tools accept an optional \`model\` field to override the agent's default model:
- DelegateTask with model: "claude-opus-4-5" → uses that model for this task only
- CreatePipeline steps can each have their own model, or set a top-level default
- CreateLoop with model: "gemini-2.0-flash" → each iteration uses that model

### Agent defaults (ConfigureAgent)
Use ConfigureAgent to configure per-agent defaults that apply when no per-task override is set:
- action: "set", apiKey: "sk-..." → store API key
- action: "set", baseUrl: "https://proxy.example.com/v1" → route requests through a proxy
- action: "set", model: "claude-opus-4-5" → default model for all tasks using that agent
- action: "check" → see current auth status and stored config (baseUrl/model shown when set)
- action: "reset" → clear all stored config for the agent

Model resolution order (highest priority wins):
1. Per-task \`model\` field (DelegateTask, pipeline step, etc.)
2. Agent config default (\`ConfigureAgent\` set model)
3. Agent's built-in default

## Key Principles

1. **Parallelize when possible**: Independent tasks should run concurrently. Only use dependsOn when ordering matters.
Expand Down
13 changes: 0 additions & 13 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ import { SQLiteLoopRepository } from './implementations/loop-repository.js';
import { SQLiteOrchestrationRepository } from './implementations/orchestration-repository.js';
import { BufferedOutputCapture } from './implementations/output-capture.js';
import { SQLiteOutputRepository } from './implementations/output-repository.js';
import { ClaudeProcessSpawner } from './implementations/process-spawner.js';
import { ProcessSpawnerAdapter } from './implementations/process-spawner-adapter.js';
import { SystemResourceMonitor } from './implementations/resource-monitor.js';
import { SQLiteScheduleRepository } from './implementations/schedule-repository.js';
Expand Down Expand Up @@ -319,18 +318,6 @@ export async function bootstrap(options: BootstrapOptions = {}): Promise<Result<
// Register core services
container.registerSingleton('taskQueue', () => new PriorityTaskQueue());

container.registerSingleton('processSpawner', () => {
// Use injected ProcessSpawner if provided (for testing)
if (options.processSpawner) {
logger.info('Using injected ProcessSpawner');
return options.processSpawner;
}

const configResult = container.get<Configuration>('config');
if (!configResult.ok) throw new Error('Config required for ProcessSpawner');
return new ClaudeProcessSpawner(configResult.value, 'claude');
});

// Register AgentRegistry for multi-agent support (v0.5.0)
// ARCHITECTURE: If a custom ProcessSpawner is injected (tests), wrap it in a
// compatibility adapter. Otherwise, register all 4 agent adapters.
Expand Down
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ if (mainCommand === 'mcp') {
timeout?: number;
maxOutputBuffer?: number;
agent?: string;
model?: string;
} = {};

let promptWords: string[] = [];
Expand Down Expand Up @@ -165,6 +166,15 @@ if (mainCommand === 'mcp') {
ui.error(`--agent requires an agent name (${AGENT_PROVIDERS.join(', ')})`);
process.exit(1);
}
} else if (arg === '--model' || arg === '-m') {
const next = foregroundArgs[i + 1];
if (next && !next.startsWith('-')) {
options.model = next;
i++;
} else {
ui.error('--model requires a model name (e.g. claude-opus-4-5)');
process.exit(1);
}
} else if (arg.startsWith('-')) {
ui.error(`Unknown flag: ${arg}`);
process.exit(1);
Expand All @@ -183,13 +193,15 @@ if (mainCommand === 'mcp') {
' -p, --priority P0|P1|P2 Task priority (P0=critical, P1=high, P2=normal)',
' -w, --working-directory DIR Working directory for task execution',
' -a, --agent AGENT AI agent to use (claude, codex, gemini)',
' -m, --model MODEL Model override (e.g. claude-opus-4-5)',
' -t, --timeout MS Task timeout in milliseconds',
' --max-output-buffer BYTES Maximum output buffer size',
'',
'Examples:',
' beat run "refactor auth" # Fire-and-forget (default)',
' beat run "quick fix" --foreground # Stream output, wait',
' beat run "analyze code" --agent codex # Use Codex instead of Claude',
' beat run "analyze code" --model claude-opus-4-5 # Use specific model',
'',
].join('\n'),
);
Expand Down
48 changes: 38 additions & 10 deletions src/cli/commands/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export async function agentsConfigSet(
value: string | undefined,
): Promise<void> {
if (!agent || !key || !value) {
ui.error('Usage: beat agents config set <agent> apiKey <value>');
ui.error('Usage: beat agents config set <agent> <apiKey|baseUrl|model> <value>');
process.exit(1);
}

Expand All @@ -103,23 +103,40 @@ export async function agentsConfigSet(
process.exit(1);
}

if (key !== 'apiKey') {
ui.error(`Unknown config key: "${key}". Valid keys: apiKey`);
if (key !== 'apiKey' && key !== 'baseUrl' && key !== 'model') {
ui.error(`Unknown config key: "${key}". Valid keys: apiKey, baseUrl, model`);
process.exit(1);
}

ui.note(
'API keys passed as CLI arguments may be stored in shell history. Consider using an environment variable instead.',
'Warning',
);
// Shell history warning only for apiKey (not baseUrl/model which are not secrets)
if (key === 'apiKey') {
ui.note(
'API keys passed as CLI arguments may be stored in shell history. Consider using an environment variable instead.',
'Warning',
);
}

// Validate baseUrl is a well-formed absolute URL before saving
if (key === 'baseUrl' && value !== '') {
try {
new URL(value);
} catch {
ui.error(`Invalid baseUrl: "${value}" is not a valid URL. Example: https://proxy.example.com/v1`);
process.exit(1);
}
}

const result = saveAgentConfig(agent, key, value);
if (!result.ok) {
ui.error(result.error);
process.exit(1);
}

ui.success(`${agent}.${key} saved (${maskApiKey(value)})`);
if (key === 'apiKey') {
ui.success(`${agent}.${key} saved (${maskApiKey(value)})`);
} else {
ui.success(`${agent}.${key} saved: ${value}`);
}
process.exit(0);
}

Expand All @@ -139,10 +156,21 @@ export async function agentsConfigShow(agent?: string): Promise<void> {
const config = loadAgentConfig(p);
const auth = AGENT_AUTH[p];

const parts: string[] = [];
if (config.apiKey) {
lines.push(`${p.padEnd(10)} apiKey: ${maskApiKey(config.apiKey)} (env var: ${auth.envVars[0]})`);
parts.push(`apiKey: ${maskApiKey(config.apiKey)} (env var: ${auth.envVars[0]})`);
}
if (config.baseUrl) {
parts.push(`baseUrl: ${config.baseUrl}`);
}
if (config.model) {
parts.push(`model: ${config.model}`);
}

if (parts.length > 0) {
lines.push(`${p.padEnd(10)} ${parts.join(' | ')}`);
} else {
lines.push(`${p.padEnd(10)} ${ui.dim('(no stored key)')}`);
lines.push(`${p.padEnd(10)} ${ui.dim('(no stored config)')}`);
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/cli/commands/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ interface OrchestrateCreateParsed {
readonly goal: string;
readonly workingDirectory?: string;
readonly agent?: AgentProvider;
readonly model?: string;
readonly maxDepth?: number;
readonly maxWorkers?: number;
readonly maxIterations?: number;
Expand Down Expand Up @@ -128,6 +129,7 @@ type OrchestrateParsed =
export function parseOrchestrateCreateArgs(args: readonly string[]): Result<OrchestrateCreateParsed, string> {
let workingDirectory: string | undefined;
let agent: AgentProvider | undefined;
let model: string | undefined;
let maxDepth: number | undefined;
let maxWorkers: number | undefined;
let maxIterations: number | undefined;
Expand All @@ -150,6 +152,11 @@ export function parseOrchestrateCreateArgs(args: readonly string[]): Result<Orch
if (!isAgentProvider(next)) return err(`Unknown agent: "${next}". Available: ${AGENT_PROVIDERS.join(', ')}`);
agent = next;
i++;
} else if (arg === '--model' || arg === '-m') {
const next = args[i + 1];
if (!next || next.startsWith('-')) return err('--model requires a model name (e.g. claude-opus-4-5)');
model = next;
i++;
} else if (arg === '--max-depth') {
const parsed = parseIntFlag('--max-depth', args[i + 1], 1, 10);
if (!parsed.ok) return parsed;
Expand Down Expand Up @@ -180,6 +187,7 @@ export function parseOrchestrateCreateArgs(args: readonly string[]): Result<Orch
goal,
workingDirectory,
agent,
model,
maxDepth,
maxWorkers,
maxIterations,
Expand Down Expand Up @@ -288,6 +296,7 @@ async function handleOrchestrateForeground(parsed: OrchestrateCreateParsed): Pro
goal: parsed.goal,
workingDirectory: parsed.workingDirectory,
agent: parsed.agent,
model: parsed.model,
maxDepth: parsed.maxDepth,
maxWorkers: parsed.maxWorkers,
maxIterations: parsed.maxIterations,
Expand Down Expand Up @@ -377,6 +386,7 @@ async function handleOrchestrateStatus(orchestratorId: string): Promise<void> {
stateFilePath: o.stateFilePath,
workingDirectory: o.workingDirectory,
agent: o.agent,
...(o.model && { model: o.model }),
maxDepth: o.maxDepth,
maxWorkers: o.maxWorkers,
maxIterations: o.maxIterations,
Expand Down Expand Up @@ -497,6 +507,7 @@ export async function handleOrchestrateCommand(
' -f, --foreground Block and wait for completion',
' -w, --working-directory DIR Working directory for workers',
' -a, --agent AGENT AI agent (claude, codex, gemini)',
' -m, --model MODEL Model override (e.g. claude-opus-4-5)',
' --max-depth N Max delegation depth (1-10, default: 3)',
' --max-workers N Max concurrent workers (1-20, default: 5)',
' --max-iterations N Max orchestrator iterations (1-200, default: 50)',
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export async function runTask(
timeout?: number;
maxOutputBuffer?: number;
agent?: string;
model?: string;
},
): Promise<void> {
let container: Container | undefined;
Expand Down Expand Up @@ -167,6 +168,7 @@ export async function runTask(
if (options.priority) params.push(`Priority: ${options.priority}`);
if (options.workingDirectory) params.push(`Dir: ${options.workingDirectory}`);
if (options.agent) params.push(`Agent: ${options.agent}`);
if (options.model) params.push(`Model: ${options.model}`);
if (options.dependsOn && options.dependsOn.length > 0) params.push(`Deps: ${options.dependsOn.join(', ')}`);
if (options.continueFrom) params.push(`Continue from: ${options.continueFrom}`);
if (options.timeout) params.push(`Timeout: ${ui.formatMs(options.timeout)}`);
Expand All @@ -181,6 +183,7 @@ export async function runTask(
dependsOn: options?.dependsOn?.map((id: string) => TaskId(id)),
continueFrom: options?.continueFrom ? TaskId(options.continueFrom) : undefined,
agent: options?.agent as AgentProvider | undefined,
model: options?.model,
};

const result = await taskManager.delegate(request);
Expand Down
19 changes: 18 additions & 1 deletion src/core/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ export const AGENT_DESCRIPTIONS: Readonly<Record<AgentProvider, string>> = Objec
gemini: 'Gemini CLI (Google)',
});

/**
* Base URL environment variable names per agent provider
* Used by BaseAgentAdapter.resolveBaseUrl() to check user env before config
* Single source of truth — infrastructure-level override
*/
export const AGENT_BASE_URL_ENV: Readonly<Record<AgentProvider, string>> = Object.freeze({
claude: 'ANTHROPIC_BASE_URL',
codex: 'OPENAI_BASE_URL',
gemini: 'GEMINI_BASE_URL',
});

/**
* Auth requirements per agent provider — single source of truth
*
Expand Down Expand Up @@ -224,9 +235,15 @@ export interface AgentAdapter {
* @param prompt - The task prompt to execute
* @param workingDirectory - Directory to run in
* @param taskId - Optional task ID for identification
* @param model - Optional model override (per-task model overrides agent config model)
* @returns Process handle with PID, or error
*/
spawn(prompt: string, workingDirectory: string, taskId?: string): Result<{ process: ChildProcess; pid: number }>;
spawn(
prompt: string,
workingDirectory: string,
taskId?: string,
model?: string,
): Result<{ process: ChildProcess; pid: number }>;

/**
* Kill an agent process by PID
Expand Down
24 changes: 22 additions & 2 deletions src/core/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ export function resetConfigValue(key: string): ConfigWriteResult {

export interface AgentConfig {
readonly apiKey?: string;
readonly baseUrl?: string;
readonly model?: string;
}

/**
Expand All @@ -271,13 +273,23 @@ export function loadAgentConfig(provider: AgentProvider): AgentConfig {
const record = section as Record<string, unknown>;
return {
apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined,
baseUrl: typeof record.baseUrl === 'string' ? record.baseUrl : undefined,
model: typeof record.model === 'string' ? record.model : undefined,
};
}

/**
* Save a key-value pair under the `agents.<provider>` section of config.json
*
* Edge cases:
* - Empty string: deletes the key instead of saving it
* - baseUrl: strips trailing slash before saving
*/
export function saveAgentConfig(provider: AgentProvider, key: 'apiKey', value: string): ConfigWriteResult {
export function saveAgentConfig(
provider: AgentProvider,
key: 'apiKey' | 'baseUrl' | 'model',
value: string,
): ConfigWriteResult {
const existing = loadConfigFile();
const agents = (
existing.agents && typeof existing.agents === 'object' && !Array.isArray(existing.agents) ? existing.agents : {}
Expand All @@ -286,7 +298,15 @@ export function saveAgentConfig(provider: AgentProvider, key: 'apiKey', value: s
agents[provider] && typeof agents[provider] === 'object' && !Array.isArray(agents[provider]) ? agents[provider] : {}
) as Record<string, unknown>;

section[key] = value;
if (value === '') {
// Empty string clears the key
delete section[key];
} else {
// Normalize baseUrl: strip trailing slash
const normalized = key === 'baseUrl' ? value.replace(/\/$/, '') : value;
section[key] = normalized;
}

agents[provider] = section;
existing.agents = agents;
return writeConfigFile(existing);
Expand Down
Loading
Loading