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
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion crates/broker/src/runtime/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Comment on lines +542 to +545

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve inline models on Relaycast spawns

Apply this effective-model resolution to the fleet/Relaycast action.invoke path as well. spawn_worker_from_request in runtime/relaycast_events.rs calls relay_skill_prefix using the pinned spec.model before WorkerRegistry::spawn; the correction at worker.rs:1110 therefore happens too late. For example, a Relaycast spawn with cli: "claude --model haiku" and model: "opus" runs Haiku but omits the small-model guidance because the prefix was selected as Opus, undermining the reliability guarantee documented for that guidance.

Useful? React with 👍 / 👎.

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());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relaycast skips inline model reconciliation

Medium Severity

Inline --model/-m is reconciled onto spec.model only on the HTTP spawn path before relay_skill_prefix. The Relaycast path still prefixes from the pin, so a superseded pin can inject or omit small-model guidance the running harness never sees.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7c74ae4. Configure here.

if !skip_relay_prompt {
if let Some(prefix) = relay_skill_prefix(
spec.cli.as_deref().unwrap_or(&cli),
Expand Down
109 changes: 109 additions & 0 deletions crates/broker/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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=")
Expand Down Expand Up @@ -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(&[
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/cli/commands/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CoreTeamsConfig {
agents: Array<{
name: string;
cli: string;
model?: string;
task?: string;
}>;
}
Expand All @@ -48,6 +49,7 @@ export interface CoreRelay {
spawn: (input: {
name: string;
cli: string;
model?: string;
channels: string[];
args?: string[];
task?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/lib/broker-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Inline overrides retain the wrong model

When cli overrides agent.model, spec.model retains the pin while the harness runs the inline model. Worker metadata and model-based prompt selection then use the wrong model.

Learn more

The auto-spawn request now carries both the configured pin and inline CLI arguments. The broker detects an inline --model or -m in resolve_model_flag_for_cli and returns no injected model flag. However, the original pin remains in spec.model. That field is later exposed by worker listings and spawn events, used for telemetry attribution, and passed to relay_skill_prefix. The actual inline model and recorded model therefore diverge.

Example: With { "cli": "claude --model haiku", "model": "opus" }, Claude runs Haiku. Relay reports Opus and omits the small-model relay prefix because it evaluates the stale Opus pin.

Recommended fix: Parse the effective inline model in the broker and replace spec.model with it when an override is present. Keep suppressing the separately injected flag so the CLI receives only one model selection.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

channels: ['general'],
task: agent.task ?? '',
team: teamsConfig.team,
Expand Down
13 changes: 13 additions & 0 deletions packages/config/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
Loading
Loading