Skip to content

End-to-end per-agent provider and model configuration#1968

Open
IamAdamJowett wants to merge 8 commits into
nanocoai:mainfrom
IamAdamJowett:feat/per-agent-provider-and-model-config
Open

End-to-end per-agent provider and model configuration#1968
IamAdamJowett wants to merge 8 commits into
nanocoai:mainfrom
IamAdamJowett:feat/per-agent-provider-and-model-config

Conversation

@IamAdamJowett
Copy link
Copy Markdown

Summary

Five commits that collectively make per-agent provider and model selection a first-class, fully-chat-driveable feature. Each commit is individually reviewable; they chain because later commits genuinely depend on earlier ones. Applying the chain in order produces a system where:

  • A session can override its agent group's provider / model via DB column.
  • Operators can create and reconfigure sub-agents with specific provider + model from chat, no post-creation DB surgery.
  • Sub-agent changes take effect on the next spawn (running containers are stopped so cached env is refreshed).

Chain

1. fix(container-runner): propagate resolved agent_provider via env

buildContainerArgs was accepting a provider: string parameter but never using it. The documented sessions.agent_provider → agent_groups.agent_provider → container.json.provider → 'claude' precedence resolved correctly on the host and then got silently dropped before reaching the container — so session-level overrides only worked if you also manually edited container.json.provider, which no code path does.

Fix: push -e AGENT_PROVIDER=${provider} on every spawn. Agent-runner's config.ts prefers the env over container.json.provider. New pure resolveProvider(env, cfg) helper, 7 tests.

2. feat(model): add per-agent model selection (sessions.model / agent_groups.model)

Mirrors the provider pattern for the model axis. Migration 014 adds model TEXT to agent_groups and sessions. Host resolves sessions.model → agent_groups.model → container.json.model → undefined and propagates as -e AGENT_MODEL=... when any layer set one.

  • Claude provider passes model: this.model into the SDK's query options — Claude Code auto-enables the context-1m-2025-08-07 beta on the sonnet[1m] / opus[1m] suffix, so operators get 1M-context agents with no extra flag.
  • Container-side resolveModel(env, cfg) mirrors resolveProvider. 15 new tests (host + container).
  • Model strings pass through opaquely (case preserved, trimmed only) because SDK naming is case-sensitive.

Note: Codex's provider (on the providers branch) would also benefit from consuming options.model; that's a one-liner companion change against providers rather than here, since codex.ts doesn't exist on main.

3. feat(create_agent): accept optional agent_provider

Extends the create_agent MCP tool with an optional agent_provider parameter (normalised to lowercase, case-insensitive). Propagated through the delivery action into createAgentGroup instead of the previous hardcoded null, so a parent agent can spawn a sub-agent on any provider in a single call.

4. feat(create_agent): accept optional model

Same pattern for the model. create_agent({name, agent_provider?, model?, instructions?}) now fully parameterises the new agent's identity at birth. Model strings are opaque — preserved case so sonnet[1m] round-trips correctly.

5. feat(update_agent): MCP tool for reconfiguring existing sub-agents

Closes the create/update pair. update_agent({target, agent_provider?, model?}) resolves target via the caller's destination namespace (implicit auth — can only touch agents you can message), applies partial updates to agent_groups, and stops any running container for the target so its next message respawns with the new env. Empty string clears a field; omitted key leaves it unchanged.

Why chain and not separate PRs

  • (2) textually depends on (1) because both edit the same docker-run-args block in container-runner.ts (AGENT_PROVIDER and AGENT_MODEL pushes are adjacent).
  • (4) depends on (3) because they edit the same MCP-tool schema and delivery-action handler lines.
  • (4) and (5) both touch the agent_groups.model column from (2) — split out, they'd need their own migrations or odd partial landings.

Splitting further is possible but adds coordination overhead. Each commit's message is self-contained; reviewing commit-by-commit is straightforward.

Test plan

  • All existing host + container tests pass
  • New tests added per commit (resolveProvider × 7, resolveProviderName × 6 existing, resolveModel × 8, resolveModelName × 7)
  • Manual end-to-end: create sub-agent with specific provider + model, messages route correctly, update_agent flips model, next message spawns fresh container with new AGENT_MODEL

🤖 Generated with Claude Code

Adam and others added 5 commits April 24, 2026 15:35
buildContainerArgs accepted the resolved provider string as a
parameter but never used it. The documented
sessions.agent_provider → agent_groups.agent_provider →
container.json.provider → 'claude' precedence only took effect when
someone also set 'provider' in the per-agent-group container.json —
which no code path does. Session-level overrides silently resolved
correctly on the host and then got dropped before reaching the
container, so every spawn defaulted to 'claude' regardless of DB
state.

Container-runner now pushes '-e AGENT_PROVIDER=${provider}' on every
spawn. Agent-runner's config loader prefers that env over the
per-group container.json, keeping container.json as a passive
fallback for dev/test. Adds a pure resolveProvider(envValue,
configValue) helper with precedence / empty / whitespace / non-string
tests.

This makes per-session and per-agent-group provider overrides work
end-to-end for every provider — required for the Codex install above
and anything future installed via the providers branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oups.model)

Mirrors the agent_provider precedence ladder so the same "flip in the
DB, it just works" pattern extends to model choice. Motivation:
different roles want different model/size points — Opus[1m] for deep
reasoning, Sonnet[1m] as default workhorse, Haiku for cheap
high-volume subtasks, gpt-5.4-mini for Codex work. Before this, every
Claude-backed agent ran whatever the SDK defaulted to (no --model
flag passed), and there was no way to say "Derek gets opus[1m], QA
gets haiku" short of forking the provider.

Resolution ladder (same shape as agent_provider):

  sessions.model
    → agent_groups.model
    → container.json.model
    → provider SDK default (undefined)

Model strings are opaque: preserved case, just trimmed. SDK
conventions like `sonnet[1m]`, `opus[1m]`, `haiku` for Claude or
`gpt-5.4-mini` for Codex pass through untouched.

- Migration 014: adds `model TEXT` column to `agent_groups` and
  `sessions` (nullable).
- Types: AgentGroup and Session gain `model: string | null`; every
  construction site updated to pass `model: null`.
- Host-side resolveModelName() helper in container-runner.ts +
  7 precedence tests; container-runner pushes `-e AGENT_MODEL=${value}`
  when any layer specifies one (omitted otherwise so the SDK keeps
  its default).
- container.json: new optional `model?: string` field.
- Container-side resolveModel() in config.ts (mirror of
  resolveProvider) + 8 tests; RunnerConfig.model is optional.
- ProviderOptions.model threaded through index.ts into each provider.
- Claude provider passes `model: this.model` into sdkQuery options
  (SDK handles `sonnet[1m]` / `opus[1m]` beta auto-switching).
- Codex provider: options.model now wins over the legacy
  CODEX_MODEL env so the generic knob behaves uniformly; CODEX_MODEL
  and the baked-in default remain as fallbacks for backward compat.

Design Team's current default-undefined model means Claude Code SDK
picks whichever is its current default (Sonnet without 1M today).
Flip any agent group to `opus[1m]`, `sonnet[1m]`, etc. via the DB
once you want to assign roles per-model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…run on any provider

Before: create_agent only took `name` and `instructions` and hardcoded
`agent_provider: null` on the host, so every sub-agent spawned by a
parent agent ran on Claude regardless of what mix the operator wanted.

Now: the MCP tool exposes an optional `agent_provider` parameter
("claude" | "codex" | "opencode" | "mock" | …); it's normalized to
lowercase on the container side and threaded through the delivery
action into agent_groups.agent_provider on the host. Omitting the
parameter preserves the previous default-to-claude behaviour.

This lets a Design Team agent say "create a Codex sub-agent called
Illustrator" in a single turn without the operator having to flip the
DB row afterwards, and more generally lets provider choice be a
per-role design decision instead of a whole-deployment one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the earlier agent_provider extension: now both knobs flow
through the tool payload in one create_agent call. Before this, a
parent agent that wanted a Codex sub-agent on, say, gpt-5.4 had to
call create_agent and then the operator had to hand-UPDATE the
agent_groups.model column after the fact — effectively blocking
agents from self-assembling teams with heterogenous models.

The model string is passed through opaquely (trim only, no
lowercasing) because SDK model names are case-sensitive — `sonnet[1m]`
and `gpt-5.4` need to round-trip exactly as the caller wrote them.

Both agent_provider and model are surfaced in the ack-message shown
to the creator and in the "Agent group created" info log, so the
configuration of a new sub-agent is visible at creation time without
follow-up DB inspection.

No new unit tests — follows the exact shape of the existing
agent_provider handling (which is already covered) and the value
just lands in the agent_groups row that's tested end-to-end by the
existing DB test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the gap between create_agent (accepts model / agent_provider
at birth) and reality (operators wanted to change a sub-agent's
model mid-flight without DB surgery).

## Tool surface

    update_agent({
      target: "<destination-name>",
      agent_provider?: "claude" | "codex" | …,
      model?: "sonnet[1m]" | "opus[1m]" | …,
    })

At least one of agent_provider / model must be supplied. Passing
empty string clears a field (falls through to whatever's next in the
precedence ladder). Omitted keys leave the existing value alone —
partial updates work.

## Authorization

Implicit via the caller's destination namespace: the target name is
resolved through getDestinationByName(session.agent_group_id, name)
and must be an agent-type destination. You can only update
sub-agents you can message. No global admin check — mirrors the
create_agent authority model.

## Takes effect on next spawn

The agent_groups row is updated synchronously, but the sub-agent's
running container (if any) has already cached AGENT_PROVIDER /
AGENT_MODEL at spawn time. To make the change visible on the next
message rather than "eventually," the handler stops every running
container for the target agent group. Next message traffic wakes a
fresh container with the new env.

## Plumbing

- container/agent-runner/src/mcp-tools/agents.ts: updateAgent tool
  registered alongside createAgent.
- src/modules/agent-to-agent/update-agent.ts: handleUpdateAgent
  delivery-action handler.
- src/modules/agent-to-agent/index.ts: register the new action.

No new DB migration — writes to existing columns via the existing
updateAgentGroup helper. Tags along with prettier reformats on
agent-groups.ts / sender-approval.test.ts from the same edit path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4ccf62a94

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +98 to +101
updateAgentGroup(targetGroup.id, updates);

// Stop any currently-running container for the target so the next
// message wakes it fresh with the new config. No-op if none running.
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 Clear stored continuation when agent provider is updated

This handler updates agent_provider and restarts running containers, but it does not reset each target session’s persisted continuation token. On the next spawn, runPollLoop() resumes whatever was in session_state; after a provider switch that token may be invalid, causing the first user turn to fail and still be marked completed (the message is consumed but not retried). This shows up whenever agent_provider changes across providers with incompatible continuation formats, so the update flow should clear continuation state for affected sessions before/while stopping containers.

Useful? React with 👍 / 👎.

Comment on lines +123 to +125
const agentProvider = hasProvider
? (typeof rawProvider === 'string' && rawProvider.trim() ? rawProvider.trim().toLowerCase() : null)
: undefined;
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 Reject non-string provider values in update_agent

When agent_provider is present but not a string, this coercion turns it into null (clear-provider) instead of treating it as invalid input. Because MCP call arguments are forwarded to handlers as raw JSON, malformed calls can reach this path and silently wipe an existing provider override instead of failing fast, which is hard to diagnose. Validate the type explicitly and return an error when a provided value is not a string (same pattern applies to model).

Useful? React with 👍 / 👎.

Without this, `model: 'sonnet[1m]'` / `model: 'opus[1m]'` silently
fell back to the SDK default (currently sonnet-4-6) because the
Claude Code CLI requires both `--model <name>[1m]` AND
`--betas context-1m-2025-08-07` to actually switch on the 1M-context
window — specifying only the suffix was being parsed and then
discarded. Observed symptom: setting `agent_groups.model = 'opus[1m]'`
produced a container running with `AGENT_MODEL=opus[1m]` in env and
`--model opus[1m]` on the claude CLI, but no `--betas` flag; the
model self-identified as `claude-sonnet-4-6 (1M context)` rather
than Opus.

Fix: pure `betasForModel(model)` helper returns
`['context-1m-2025-08-07']` when the model string has a trailing
`[1m]` (case-insensitive, whitespace-trimmed), undefined otherwise.
ClaudeProvider.query now passes `betas: betasForModel(this.model)`
alongside `model: this.model`, so the SDK spawns the CLI with both
flags and the context-1m beta actually activates.

6 new unit tests covering: suffix detection for common model
aliases, case-insensitivity, whitespace trimming, the negative
cases (plain models return undefined), undefined/non-string input,
and mid-string [1m] being rejected (must be trailing).

Belongs upstream — any Claude-backed agent using the per-agent model
selection with a `[1m]` suffix would hit the same silent fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@IamAdamJowett
Copy link
Copy Markdown
Author

Pushed 4b827cd — important follow-up fix that completes this PR's story.

Setting agent_groups.model='opus[1m]' silently fell back to the SDK default (sonnet-4-6) because the Claude Code CLI requires both --model <name>[1m] AND --betas context-1m-2025-08-07 to actually switch on the 1M window. Specifying only the suffix was being parsed and discarded. Symptom was visible by comparing the live process flags:

# v1 install (works):  --model sonnet[1m] --betas context-1m-2025-08-07
# Before this fix:    --model opus[1m]   [no --betas]

New pure betasForModel(model) helper returns ['context-1m-2025-08-07'] for any trailing [1m] suffix (case-insensitive, whitespace-trimmed). ClaudeProvider.query now passes betas: betasForModel(this.model) alongside model: this.model, so the SDK spawns the CLI with both flags. 6 new unit tests cover suffix detection, case, trimming, negative cases, and bad input.

Without this, the per-agent-model feature in this PR is effectively dead on arrival for any user who sets a [1m] alias — they'd see the model they asked for on the CLI but get sonnet-4-6 in practice. Verified end-to-end by setting agent_groups.model='opus[1m]', respawning the container, and confirming the live claude process has --betas context-1m-2025-08-07 and the model self-reports as claude-opus-4-7 (1M context).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant