From 28b174aea0a2e792c9245fce14f308e075d731d7 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 22 Sep 2026 19:28:08 +0000 Subject: [PATCH 1/5] feat(config): support per-agent model pins in teams.json --- CHANGELOG.md | 6 +- crates/broker/src/worker.rs | 20 ++++++ packages/cli/src/cli/commands/core.test.ts | 36 ++++++++++ packages/cli/src/cli/commands/core.ts | 2 + packages/cli/src/cli/lib/broker-lifecycle.ts | 1 + packages/config/src/schemas.test.ts | 13 ++++ packages/config/src/schemas.ts | 1 + packages/config/src/teams-config.test.ts | 76 ++++++++++++++++++++ packages/config/src/teams-config.ts | 28 +++++++- 9 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 packages/config/src/teams-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f8707793c..76ec3fa3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] + +### Added + +- `teams.json` agents accept a per-agent `model` field when `up --spawn` starts them; an explicit `--model` or `-m` inside `cli` still wins. ### Fixed diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 4dc39c3117..332b0f57cc 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -4271,6 +4271,26 @@ 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 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 a7f048d0f4..082726db6b 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 605ce8b9c7..3c0e61d7f9 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 3dd1cb5ea6..fc7dff1efe 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 31fb5efd84..6fd1674fe5 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 6bc8cfa34b..f8249aaa32 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 0000000000..1117c4bd33 --- /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 5628ca0873..17fe477624 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( From 5e5baa4d6e504cc423883af72fa4704b2bf20291 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 22 Sep 2026 19:28:46 +0000 Subject: [PATCH 2/5] refactor(utils): deprecate legacy colon model mapping --- .../compact_219eq595nnmd_2026-09-22.json | 55 +++++++++++++++++++ .../compact_219eq595nnmd_2026-09-22.md | 19 +++++++ CHANGELOG.md | 4 ++ packages/utils/src/model-mapping.ts | 8 ++- summary.md | 17 ++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.json create mode 100644 .agentworkforce/trajectories/compacted/compact_219eq595nnmd_2026-09-22.md create mode 100644 summary.md 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 0000000000..9210f30824 --- /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 0000000000..784571d8ef --- /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 76ec3fa3d9..6e3d79b894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `teams.json` agents accept a per-agent `model` field when `up --spawn` starts them; an explicit `--model` or `-m` inside `cli` still wins. +### 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. + ### Fixed - Broker `manual_flush` recovery now replays a missing cumulative-ACK predecessor without duplicating an already-completed PTY injection, restores it ahead of parked successors, and reports the head/ACK/received sequence gap plus the reconciliation action in `message flush` and `message auto` results. diff --git a/packages/utils/src/model-mapping.ts b/packages/utils/src/model-mapping.ts index 10119d13c5..90ebfea9f5 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') diff --git a/summary.md b/summary.md new file mode 100644 index 0000000000..0d08c30d3f --- /dev/null +++ b/summary.md @@ -0,0 +1,17 @@ +# Per-agent model pins in teams.json + +Agents configured with `{"name":"Worker","cli":"claude","model":"opus"}` now retain their model pin through config loading and `up` auto-spawn (including `up --spawn`). The config schema and core spawn API expose the optional field. Model names are trimmed; invalid or empty values warn and are omitted without dropping the agent. Normalization preserves role/task and excludes unknown agent keys. + +Harness-specific handling stays in the existing broker path, which already carries model on the wire and emits model arguments for Claude, Codex, and OpenCode. No second TypeScript translation layer or catalog validation is introduced. Explicit inline `--model`/`-m` continues to win. The CLI doc-comment now recommends a separate model field and explains the inline-argument escape hatch and its node-capacity limitation. + +The obsolete public `mapModelToCli` and `getBaseCli` helpers are deprecated for removal next major. Their root and subpath exports remain intact: the reviewed plan reserved breaking removal for a release-level decision, so this change takes the compatibility-preserving option and raises the pending changelog to Minor. `model-commands.ts`, broker colon-syntax branches, and node-capacity normalization remain follow-ups. No workflow files or production Rust code changed. + +Validation: + +- `npm test -w @agent-relay/config`: 103 passed, including loader normalization and schema round-trip/description regressions. +- `npm run build -w @agent-relay/config`: passed. +- `npm test -w @agent-relay/utils`: 235 passed. +- Focused CLI run (`core.test.ts`, `fleet-sidecar.test.ts`, `client-factory.test.ts`): 178 passed, including model forwarding for Claude, Codex, OpenCode, and inline arguments. The existing exact no-model spawn assertion remains green. +- Full CLI run (`npx vitest run packages/cli/src`): 1,885 passed, 17 skipped, 2 failed. Both failures reproduce on unmodified `b274b7a` in an isolated worktree: `sdk-client.test.ts` expects no gateway URL but receives `https://cast.agentrelay.com`; `fleet-lifecycle-integration.test.ts` exits from local-agent routing. +- Added a hermetic broker test covering all four inline model override forms and the no-override path using Claude. `cargo test -p agent-relay-broker model` could not run because Cargo/Rust is not installed; broker model and ordered-PTY-argument tests remain unverified here. +- Formatting and `git diff --check`: passed. From a6dbffd7a73c53cc36a1131c92c562d141b60eec Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 22 Sep 2026 19:30:32 +0000 Subject: [PATCH 3/5] Keep relayflow working files out of the change --- summary.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 summary.md diff --git a/summary.md b/summary.md deleted file mode 100644 index 0d08c30d3f..0000000000 --- a/summary.md +++ /dev/null @@ -1,17 +0,0 @@ -# Per-agent model pins in teams.json - -Agents configured with `{"name":"Worker","cli":"claude","model":"opus"}` now retain their model pin through config loading and `up` auto-spawn (including `up --spawn`). The config schema and core spawn API expose the optional field. Model names are trimmed; invalid or empty values warn and are omitted without dropping the agent. Normalization preserves role/task and excludes unknown agent keys. - -Harness-specific handling stays in the existing broker path, which already carries model on the wire and emits model arguments for Claude, Codex, and OpenCode. No second TypeScript translation layer or catalog validation is introduced. Explicit inline `--model`/`-m` continues to win. The CLI doc-comment now recommends a separate model field and explains the inline-argument escape hatch and its node-capacity limitation. - -The obsolete public `mapModelToCli` and `getBaseCli` helpers are deprecated for removal next major. Their root and subpath exports remain intact: the reviewed plan reserved breaking removal for a release-level decision, so this change takes the compatibility-preserving option and raises the pending changelog to Minor. `model-commands.ts`, broker colon-syntax branches, and node-capacity normalization remain follow-ups. No workflow files or production Rust code changed. - -Validation: - -- `npm test -w @agent-relay/config`: 103 passed, including loader normalization and schema round-trip/description regressions. -- `npm run build -w @agent-relay/config`: passed. -- `npm test -w @agent-relay/utils`: 235 passed. -- Focused CLI run (`core.test.ts`, `fleet-sidecar.test.ts`, `client-factory.test.ts`): 178 passed, including model forwarding for Claude, Codex, OpenCode, and inline arguments. The existing exact no-model spawn assertion remains green. -- Full CLI run (`npx vitest run packages/cli/src`): 1,885 passed, 17 skipped, 2 failed. Both failures reproduce on unmodified `b274b7a` in an isolated worktree: `sdk-client.test.ts` expects no gateway URL but receives `https://cast.agentrelay.com`; `fleet-lifecycle-integration.test.ts` exits from local-agent routing. -- Added a hermetic broker test covering all four inline model override forms and the no-override path using Claude. `cargo test -p agent-relay-broker model` could not run because Cargo/Rust is not installed; broker model and ordered-PTY-argument tests remain unverified here. -- Formatting and `git diff --check`: passed. From dd1708e2089a8eeb2f0f4eb605aa9b44749cc753 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Sep 2026 19:31:56 +0000 Subject: [PATCH 4/5] style: auto-format Rust code with cargo fmt --- crates/broker/src/worker.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/broker/src/worker.rs b/crates/broker/src/worker.rs index 332b0f57cc..18be0e71eb 100644 --- a/crates/broker/src/worker.rs +++ b/crates/broker/src/worker.rs @@ -4280,8 +4280,7 @@ sleep 30 vec!["-m=sonnet".to_string()], ] { assert_eq!( - resolve_model_flag_for_cli("claude", "claude", "worker", Some("opus"), &args) - .await, + resolve_model_flag_for_cli("claude", "claude", "worker", Some("opus"), &args).await, None ); } From 78e219c6eb29571ea0942bb40ce00ea8236d8f34 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 22 Sep 2026 18:20:16 -0700 Subject: [PATCH 5/5] fix(broker): record the model argv actually names `resolve_model_flag_for_cli` returns None when argv already carries a model override, so no second flag is injected. Its callers only wrote `spec.model` when a flag came back, so an agent configured as `{"cli": "claude --model haiku", "model": "opus"}` ran Haiku while the spec, worker listings, spawn events and telemetry all said Opus. The pin also drives `relay_skill_prefix`, chosen in the HTTP spawn path before the worker starts, so the wrong model there omits the small-model relay guidance (or adds it to a large model). Repairing the metadata inside worker startup would be after that decision, so the effective model is now resolved before it. `model_override_from_args` reads `--model value`, `--model=value`, `-m value` and `-m=value`, last occurrence winning as argv does. A bare `--model` names nothing and yields None while still suppressing injection, so the harness is never handed two model flags. Addresses the review finding on PR #1845. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + crates/broker/src/runtime/api.rs | 24 ++++++++- crates/broker/src/worker.rs | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3d79b894..a64919c8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - Broker `manual_flush` recovery now replays a missing cumulative-ACK predecessor without duplicating an already-completed PTY injection, restores it ahead of parked successors, and reports the head/ACK/received sequence gap plus the reconciliation action in `message flush` and `message auto` results. ## [12.4.1] - 2026-09-22 diff --git a/crates/broker/src/runtime/api.rs b/crates/broker/src/runtime/api.rs index 1d4d237a62..e1e82a1235 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 18be0e71eb..7cd57f069e 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=") @@ -4290,6 +4332,54 @@ sleep 30 ); } + #[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(&[