diff --git a/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.json b/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.json new file mode 100644 index 000000000..9210f3082 --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.json @@ -0,0 +1,55 @@ +{ + "id": "compact_219eq595nnmd", + "version": 1, + "type": "compacted", + "compactedAt": "2026-09-22T19:28:36.361Z", + "sourceTrajectories": [ + "traj_hwpsorpvo3va" + ], + "dateRange": { + "start": "2026-09-22T19:25:17.654Z", + "end": "2026-09-22T19:28:35.620Z" + }, + "summary": { + "totalDecisions": 2, + "totalEvents": 2, + "uniqueAgents": [ + "default" + ] + }, + "decisionGroups": [ + { + "category": "database", + "decisions": [ + { + "question": "Use the existing broker model handling and document supported harnesses without catalog validation", + "chosen": "Use the existing broker model handling and document supported harnesses without catalog validation", + "reasoning": "The protocol already supports model; catalog validation would reject future models. Preserve inline model argument precedence.", + "fromTrajectory": "traj_hwpsorpvo3va" + }, + { + "question": "Deprecate public model-mapping helpers; retain root and subpath exports until the next major", + "chosen": "Deprecate public model-mapping helpers; retain root and subpath exports until the next major", + "reasoning": "The reviewed plan reserves breaking removal for an explicit release-level choice. No preference was supplied, so preserve compatibility and mark the obsolete helpers deprecated. Leave model-commands, broker colon branches and capacity normalization for follow-up.", + "fromTrajectory": "traj_hwpsorpvo3va" + } + ] + } + ], + "keyLearnings": [], + "keyFindings": [], + "filesAffected": [ + "CHANGELOG.md", + "crates/broker/src/worker.rs", + "packages/cli/src/cli/commands/core.test.ts", + "packages/cli/src/cli/commands/core.ts", + "packages/cli/src/cli/lib/broker-lifecycle.ts", + "packages/config/src/schemas.test.ts", + "packages/config/src/schemas.ts", + "packages/config/src/teams-config.test.ts", + "packages/config/src/teams-config.ts" + ], + "commits": [ + "28b174a" + ] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.md b/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.md new file mode 100644 index 000000000..784571d8e --- /dev/null +++ b/.agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.md @@ -0,0 +1,19 @@ +# Trajectory Compaction: Sep 22, 2026 - Sep 22, 2026 + +## Summary +- Sessions: 1 +- Decisions: 2 +- Events: 2 +- Agents: default +- Files: 9 +- Commits: 1 + +## Database +- Use the existing broker model handling and document supported harnesses without catalog validation -> Use the existing broker model handling and document supported harnesses without catalog validation (traj_hwpsorpvo3va) +- Deprecate public model-mapping helpers; retain root and subpath exports until the next major -> Deprecate public model-mapping helpers; retain root and subpath exports until the next major (traj_hwpsorpvo3va) + +## Key Learnings +- None + +## Key Findings +- None \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bae4b1393..d721476ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Major] +### Added + +- `teams.json` agents accept a per-agent `model` field when `up --spawn` starts them; an explicit `--model` or `-m` inside `cli` still wins. + ### Changed - Targeted `fleet spawn` now waits for harness readiness; the broker releases workers that miss its 90-second readiness window. Confirmed targeted spawns require `--confirm-timeout` of at least 95000ms. +### Deprecated + +- `@agent-relay/utils` model-mapping helpers (`mapModelToCli`, `getBaseCli`) are deprecated for removal in the next major release; use separate `cli` and `model` fields instead of non-executable colon syntax. + ### Removed - `@agent-relay/sdk`: removed `workspace.fleetNodes` and `RelayWorkspaceFleetNodesConfig`, whose underlying service API no longer exists. ### Fixed +- A `teams.json` agent whose `cli` carries an inline `--model`/`-m` now records the model the harness actually runs. The inline override becomes the spawn's effective model before the relay skill prefix is chosen, so worker listings, spawn events, telemetry and small-model guidance describe the running model rather than the superseded pin. - `agent-relay fleet config|enable|disable|inherit` now exit successfully as hidden compatibility no-ops instead of failing on the removed workspace rollout API. - Targeted `fleet spawn` requests explicit readiness proof, preventing healthy launches from being rejected for missing proof; unconfirmed launches report `ready:false` while obsolete handlers remain rejected. - `@agent-relay/sdk` `placement.spawn` only asks a node to verify readiness when it will wait for the answer, and confirms against the contract it requested, so `confirm` omitted no longer arms a 90-second readiness kill switch and `verifyReady: false` no longer fails a healthy launch. diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index 1d4d237a6..e1e82a123 100644 --- a/crates/broker/src/runtime/api.rs +++ b/crates/broker/src/runtime/api.rs @@ -409,7 +409,7 @@ impl BrokerRuntime { return; } }; - let spec = match build_http_api_spawn_spec( + let mut spec = match build_http_api_spawn_spec( name.clone(), cli.clone(), transport, @@ -533,6 +533,28 @@ impl BrokerRuntime { // so all task decoration must be complete before registration // or spawn. This also lets the broker reject non-portable argv // text before creating a remote worker identity. + // An inline `--model`/`-m` in the command or the arguments is + // what the harness actually runs: it reads argv and never sees + // `spec.model`. Resolve it before the skill prefix is chosen -- + // repairing the metadata inside worker startup would be after + // this decision -- and keep the effective value on the spec so + // listings, spawn events and telemetry agree with the harness. + if let Some(inline) = crate::worker::model_override_from_args(&{ + let command = spec.cli.as_deref().unwrap_or(&cli); + let mut tokens = shlex::split(command).unwrap_or_default(); + tokens.extend(spec.args.iter().cloned()); + tokens + }) { + if spec.model.as_deref() != Some(inline) { + tracing::debug!( + agent = %name, + pinned_model = ?spec.model, + effective_model = %inline, + "argv names a model; recording it as the effective model" + ); + spec.model = Some(inline.to_string()); + } + } if !skip_relay_prompt { if let Some(prefix) = relay_skill_prefix( spec.cli.as_deref().unwrap_or(&cli), diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 4dc39c311..7cd57f069 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -847,6 +847,10 @@ impl WorkerRegistry { .await; if let Some(ref model) = model_flag { spec.model = Some(model.clone()); + } else if let Some(inline) = model_override_from_args(&effective_args) { + // Injection was suppressed because argv already names a + // model; record what the harness will actually run. + spec.model = Some(inline.to_string()); } let startup_prompt = muse_startup_prompt(&cli_lower, initial_task.as_deref()); @@ -1103,6 +1107,8 @@ impl WorkerRegistry { .await; if let Some(ref model) = model_flag { spec.model = Some(model.clone()); + } else if let Some(inline) = model_override_from_args(&effective_args) { + spec.model = Some(inline.to_string()); } let startup_prompt = muse_startup_prompt(&cli_lower, initial_task.as_deref()); @@ -1165,6 +1171,8 @@ impl WorkerRegistry { .await; if let Some(ref model) = model_arg { spec.model = Some(model.clone()); + } else if let Some(inline) = model_override_from_args(&spec.args) { + spec.model = Some(inline.to_string()); } if model_arg.is_some() || !spec.args.is_empty() || !mcp_args.is_empty() { @@ -2450,6 +2458,40 @@ fn cli_flag_present(args: &[String], flags: &[&str]) -> bool { }) } +/// The model an inline `--model`/`-m` override names, if the arguments carry +/// one with a value. The harness reads argv and never sees `spec.model`, so +/// this is the model that actually runs, and it is what listings, spawn +/// events, telemetry and relay-skill selection must describe. +/// +/// Later occurrences win, matching how the harnesses themselves read argv. +/// A bare `--model` with no value names nothing, so it yields `None` while +/// still suppressing injection through `args_include_model_override`. +pub(crate) fn model_override_from_args(args: &[String]) -> Option<&str> { + let mut found: Option<&str> = None; + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if let Some(value) = arg + .strip_prefix("--model=") + .or_else(|| arg.strip_prefix("-m=")) + { + let value = value.trim(); + if !value.is_empty() { + found = Some(value); + } + } else if arg == "--model" || arg == "-m" { + if let Some(value) = args.get(index + 1).map(|value| value.trim()) { + if !value.is_empty() { + found = Some(value); + } + index += 1; + } + } + index += 1; + } + found +} + fn args_include_model_override(args: &[String]) -> bool { args.iter().any(|arg| { arg == "--model" || arg.starts_with("--model=") || arg == "-m" || arg.starts_with("-m=") @@ -4271,6 +4313,73 @@ sleep 30 ])); } + #[tokio::test] + async fn model_pin_yields_to_inline_model_overrides() { + for args in [ + vec!["--model".to_string(), "sonnet".to_string()], + vec!["--model=sonnet".to_string()], + vec!["-m".to_string(), "sonnet".to_string()], + vec!["-m=sonnet".to_string()], + ] { + assert_eq!( + resolve_model_flag_for_cli("claude", "claude", "worker", Some("opus"), &args).await, + None + ); + } + assert_eq!( + resolve_model_flag_for_cli("claude", "claude", "worker", Some("opus"), &[]).await, + Some("opus".to_string()) + ); + } + + #[test] + fn model_override_from_args_reads_every_supported_form() { + for args in [ + vec!["--model".to_string(), "haiku".to_string()], + vec!["--model=haiku".to_string()], + vec!["-m".to_string(), "haiku".to_string()], + vec!["-m=haiku".to_string()], + ] { + assert_eq!(model_override_from_args(&args), Some("haiku"), "{args:?}"); + // The same arguments still suppress injection, so the harness is + // never handed two model flags. + assert!(args_include_model_override(&args), "{args:?}"); + } + assert_eq!(model_override_from_args(&[]), None); + assert_eq!(model_override_from_args(&["--verbose".to_string()]), None); + } + + #[test] + fn model_override_from_args_takes_the_last_one_and_ignores_a_valueless_flag() { + // argv semantics: a later flag wins. + assert_eq!( + model_override_from_args(&[ + "--model".to_string(), + "haiku".to_string(), + "--model=sonnet".to_string(), + ]), + Some("sonnet") + ); + // A trailing `--model` names nothing, so there is no effective model to + // record -- but injection stays suppressed. + let bare = vec!["--model".to_string()]; + assert_eq!(model_override_from_args(&bare), None); + assert!(args_include_model_override(&bare)); + } + + #[tokio::test] + async fn an_inline_override_is_the_effective_model_the_pin_is_not() { + // `{"cli": "claude --model haiku", "model": "opus"}`: the harness runs + // haiku, so haiku is what the spec must carry. resolve_model_flag_for_cli + // returns None here precisely so no second flag is injected. + let args = vec!["--model".to_string(), "haiku".to_string()]; + assert_eq!( + resolve_model_flag_for_cli("claude", "claude", "worker", Some("opus"), &args).await, + None + ); + assert_eq!(model_override_from_args(&args), Some("haiku")); + } + #[test] fn args_include_model_override_detects_supported_forms() { assert!(args_include_model_override(&[ diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index a7f048d0f..082726db6 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -409,6 +409,42 @@ describe('registerCoreCommands', () => { }); }); + it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.4'], + ['opencode', 'openai/gpt-5.2'], + ['claude --model sonnet', 'opus'], + ])('up forwards the model pin for %s to the broker', async (cli, model) => { + const relay = createRelayMock({ + getStatus: vi.fn(async () => ({ + agent_count: 0, + pending_delivery_count: 0, + node_connected: true, + node_delivery: { token_present: true, connected: true }, + })), + }); + const { program } = createHarness({ + relay, + teamsConfig: { + team: 'platform', + autoSpawn: true, + agents: [{ name: 'WorkerA', cli, model, task: 'Ship tests' }], + }, + }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(relay.spawn).toHaveBeenCalledWith({ + name: 'WorkerA', + cli, + model, + channels: ['general'], + task: 'Ship tests', + team: 'platform', + }); + }); + it('up refuses auto-spawn when node delivery is down', async () => { let now = 0; const relay = createRelayMock({ diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 605ce8b9c..3c0e61d7f 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -33,6 +33,7 @@ export interface CoreTeamsConfig { agents: Array<{ name: string; cli: string; + model?: string; task?: string; }>; } @@ -48,6 +49,7 @@ export interface CoreRelay { spawn: (input: { name: string; cli: string; + model?: string; channels: string[]; args?: string[]; task?: string; diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 3dd1cb5ea..fc7dff1ef 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -2439,6 +2439,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): await relay.spawn({ name: agent.name, cli: agent.cli, + ...(agent.model ? { model: agent.model } : {}), channels: ['general'], task: agent.task ?? '', team: teamsConfig.team, diff --git a/packages/config/src/schemas.test.ts b/packages/config/src/schemas.test.ts index 31fb5efd8..6fd1674fe 100644 --- a/packages/config/src/schemas.test.ts +++ b/packages/config/src/schemas.test.ts @@ -6,11 +6,24 @@ import { BridgeConfigSchema, RelayRuntimeConfigSchema, ShadowConfigSchema, + TeamsConfigSchema, jsonSchemas, } from './schemas.js'; import { DEFAULT_CONNECTION_CONFIG, DEFAULT_TMUX_WRAPPER_CONFIG } from './relay-config.js'; describe('config schemas', () => { + it('preserves per-agent model pins when parsing teams config', () => { + const config = { + team: 'platform', + agents: [{ name: 'Worker', cli: 'claude', model: 'opus' }], + }; + expect(TeamsConfigSchema.parse(config)).toEqual(config); + }); + + it('describes the per-agent model field in the published schema', () => { + expect(jsonSchemas.teams).toHaveProperty('properties.agents.items.properties.model', { type: 'string' }); + }); + it('validates connection defaults', () => { expect(ConnectionConfigSchema.parse(DEFAULT_CONNECTION_CONFIG)).toEqual(DEFAULT_CONNECTION_CONFIG); }); diff --git a/packages/config/src/schemas.ts b/packages/config/src/schemas.ts index 6bc8cfa34..f8249aaa3 100644 --- a/packages/config/src/schemas.ts +++ b/packages/config/src/schemas.ts @@ -63,6 +63,7 @@ export const TeamsConfigSchema = z.object({ z.object({ name: z.string(), cli: z.string(), + model: z.string().optional(), role: z.string().optional(), task: z.string().optional(), }) diff --git a/packages/config/src/teams-config.test.ts b/packages/config/src/teams-config.test.ts new file mode 100644 index 000000000..1117c4bd3 --- /dev/null +++ b/packages/config/src/teams-config.test.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearTeamsConfigCache, loadTeamsConfig } from './teams-config.js'; + +describe('teams config model pins', () => { + let projectRoot: string; + + beforeEach(() => { + clearTeamsConfigCache(); + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-config-')); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + clearTeamsConfigCache(); + vi.restoreAllMocks(); + }); + + function loadAgent(fields: Record) { + fs.writeFileSync( + path.join(projectRoot, 'teams.json'), + JSON.stringify({ team: 'platform', agents: [{ name: 'Worker', cli: 'claude', ...fields }] }) + ); + return loadTeamsConfig(projectRoot)?.agents[0]; + } + + it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.4'], + ['opencode', 'openai/gpt-5.2'], + ['claude', 'future-model'], + ])('loads and trims a model for %s without catalog validation', (cli, model) => { + expect(loadAgent({ cli, model: ` ${model} ` })).toEqual({ name: 'Worker', cli, model }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it.each([null, 42, false, {}, [], '', ' \t\n '])( + 'drops invalid model %j without dropping the agent', + (model) => { + expect(loadAgent({ model })).toEqual({ name: 'Worker', cli: 'claude' }); + expect(console.warn).toHaveBeenCalledWith( + "[teams-config] Agent 'Worker' has invalid 'model' field, ignoring it" + ); + } + ); + + it('preserves role and task, omits undeclared model and strips unknown keys', () => { + expect(loadAgent({ role: 'reviewer', task: 'Review tests', unknown: true })).toEqual({ + name: 'Worker', + cli: 'claude', + role: 'reviewer', + task: 'Review tests', + }); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('preserves inline model arguments alongside the model pin for broker precedence', () => { + expect(loadAgent({ cli: 'claude --model sonnet', model: 'opus' })).toEqual({ + name: 'Worker', + cli: 'claude --model sonnet', + model: 'opus', + }); + }); + + it('keeps the default CLI behavior when a model is pinned', () => { + expect(loadAgent({ cli: '', model: 'opus' })).toEqual({ + name: 'Worker', + cli: 'claude', + model: 'opus', + }); + }); +}); diff --git a/packages/config/src/teams-config.ts b/packages/config/src/teams-config.ts index 5628ca087..17fe47762 100644 --- a/packages/config/src/teams-config.ts +++ b/packages/config/src/teams-config.ts @@ -24,8 +24,18 @@ let configCache: TeamsConfigCache | null = null; export interface TeamAgentConfig { /** Agent name (used for spawn and validation) */ name: string; - /** CLI command to use (e.g., 'claude', 'claude:opus', 'codex') */ + /** + * CLI command (e.g., 'claude', 'codex', 'claude --model opus'). + * Inline --model/-m takes precedence over model. Prefer the model field: + * cli is also used as a harness name when advertising node capacity. + */ cli: string; + /** + * Model for harnesses accepting --model, including claude, codex, and opencode. + * Passed through to the broker without name validation (e.g., 'opus', + * 'openai/gpt-5.2'); the broker owns harness-specific model handling. + */ + model?: string; /** Agent role (e.g., 'coordinator', 'developer', 'reviewer') */ role?: string; /** Initial task/prompt to inject when spawning */ @@ -130,7 +140,21 @@ export function loadTeamsConfig(projectRoot: string): TeamsConfig | null { console.warn(`[teams-config] Agent '${agent.name}' missing 'cli' field, defaulting to 'claude'`); agent.cli = 'claude'; } - validAgents.push(agent); + let model: string | undefined; + if (agent.model !== undefined) { + if (typeof agent.model === 'string' && agent.model.trim()) { + model = agent.model.trim(); + } else { + console.warn(`[teams-config] Agent '${agent.name}' has invalid 'model' field, ignoring it`); + } + } + validAgents.push({ + name: agent.name, + cli: agent.cli, + ...(agent.role !== undefined ? { role: agent.role } : {}), + ...(agent.task !== undefined ? { task: agent.task } : {}), + ...(model ? { model } : {}), + }); } console.log( diff --git a/packages/utils/src/model-mapping.ts b/packages/utils/src/model-mapping.ts index 10119d13c..90ebfea9f 100644 --- a/packages/utils/src/model-mapping.ts +++ b/packages/utils/src/model-mapping.ts @@ -2,7 +2,9 @@ * Model Mapping * * Maps agent profile model identifiers to CLI variants. - * Used for cost tracking and model selection when spawning agents. + * Legacy compatibility helpers; not used by production spawn paths. + * Use separate cli and model fields in teams.json or spawn inputs. + * Colon-suffixed CLI commands are not executable harness names. */ /** @@ -27,6 +29,8 @@ const MODEL_TO_CLI: Record = { /** * Convert a model identifier into the CLI command variant. + * @deprecated Use separate cli and model fields in teams.json or spawn inputs. + * The returned colon syntax is not executable. Scheduled for removal next major. * Defaults to 'claude:sonnet' when no match is found. * * @param model - Model identifier from agent profile (e.g., 'claude-opus-4', 'sonnet') @@ -49,6 +53,8 @@ export function mapModelToCli(model?: string): string { /** * Extract the base CLI name from a model-mapped CLI variant. + * @deprecated Use separate cli and model fields instead of colon-suffixed CLI names. + * Scheduled for removal next major. * * @param cliVariant - CLI variant (e.g., 'claude:opus', 'claude', 'codex') * @returns Base CLI name (e.g., 'claude', 'codex')