Skip to content

feat(acp): let the owner switch an agent's model mid-session with !model - #3380

Open
troyhoffman-oss wants to merge 4 commits into
block:mainfrom
troyhoffman-oss:feat/model-owner-command-up
Open

feat(acp): let the owner switch an agent's model mid-session with !model#3380
troyhoffman-oss wants to merge 4 commits into
block:mainfrom
troyhoffman-oss:feat/model-owner-command-up

Conversation

@troyhoffman-oss

Copy link
Copy Markdown

!model lets the owner see and change the model backing an agent from the
channel itself, rather than the desktop picker.

  • !model — lists the agent's models as a numbered catalog, with ← current
    marking the one in force for that channel. Resolves the channel's own
    override first, then the adapter's reported selection, so a stock agent
    nobody has overridden still marks something.
  • !model <id> — validates the pick against the catalog, then switches. A
    turn in flight is cancelled and re-run on the new model; an idle agent takes
    it on its next turn. Either way the reply says which happened.
  • Unknown id — rejected visibly, with the listing, and the current model is
    left untouched. Matching is on exact ids: adapters ship near-identical pairs
    (opus[1m] vs claude-opus-5, gpt-5.3-codex vs gpt-5.3-codex/low) where
    a prefix rule would silently pick a different context lane and price point.
  • A leading mention is accepted (@Agent !model), since mentioning is how a
    client addresses an agent.

!model is a second front door onto the kind:24200 switch_model path, not a
new mechanism — both share one switch_model_for_channel implementation.

The first commit is a prerequisite refactor: is_owner_control_command
required exact content equality, so no command could carry an argument.
Replacing it with a parser that splits (command, args) collapses the three
existing inline owner checks into one match and makes !model <id> expressible
at all. Behaviour of !shutdown, !cancel, and !rotate is unchanged.

Closes #2543

… strings

`is_owner_control_command` required exact content equality, so a command
could not carry an argument and each of the three commands needed its own
inline block re-doing the same owner check. Replace it with
`owner_control_command`, which splits a kind:9 agent mention into
`(command, args)`; the three blocks collapse into one owner check plus a
match.

Arity is enforced by the arms (`("!shutdown", "")`), so "!shutdown please"
is not a shutdown, and an unrecognized command or a non-owner author still
falls through to normal prompt handling — a stranger typing "!cancel" is
still sending the agent a message. That fall-through is load-bearing now
that the parser accepts any `!token`, so it is pinned by a test rather than
left to be rediscovered.

The parser also accepts a leading mention. Mentioning is how a client
addresses an agent, so `@Name !rotate` is the natural way to type a
command, and by the time the parser runs the `p` tag has already proved the
mention is ours — the name after `@` carries no further information.
Scanning to the first whitespace-delimited `!`-token rather than skipping
one token is what makes this work on real deployments: display names hold
spaces (`@Codex (Sol)`, `@Will Pfleger`), so a single-token skip would drop
the command for most of them. Matching the name itself was the alternative
and was rejected — the harness knows its ACP adapter name, not its Nostr
profile name, so it would need a kind:0 fetch to learn a string the `p` tag
already made redundant.

The leading `@` is what arms the scan, so a bang mid-sentence still never
fires: `hello !rotate` and `hello @claude !rotate` both stay prompts. The
accepted cost is the mirror case — a message that opens with a mention and
happens to contain a bang-token later in prose now parses as a command.
That is owner-gated and rare, and it fails loudly with an unmatched arm
rather than silently swallowing the message.

Behaviour of `!shutdown`, `!cancel`, and `!rotate` is otherwise unchanged.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
`!model [id]` is a second front door onto the switch path kind:24200
`switch_model` already drives, not a new mechanism. It lands as a fourth arm
in the control-command match.

Extract `switch_model_for_channel` from `handle_switch_model_control` so both
front doors share one implementation, with `SwitchOutcome` naming the outcomes
the observer path renders as `control_result` statuses.

Matching is on exact IDs. Adapters ship near-identical pairs (`opus[1m]` vs
`claude-opus-5`, `gpt-5.3-codex` vs `gpt-5.3-codex/low`) where a prefix rule
would silently pick a different context lane and price point. A miss — or no
argument — renders the catalog, so the listing is also the error-recovery
story.

The pick is pre-validated so a typo never cancels a live turn, which requires
a catalog that is readable mid-turn. It used to live on `OwnedAgent`, and with
the default `--agents 1` the single agent is moved out of its slot for the
duration of a turn, so a slot lookup finds nothing exactly on the busy path
the guard is for. Hoist it to a process-wide `ModelCatalog` on
`PromptContext`, captured task-side by `apply_session_config`: it is a
property of the agent command, not of a session or a slot, so nothing is lost
by sharing it. Validation then sits in one place at the top of
`switch_model_for_channel`, covering both front doors equally, and
`IdleSwitchResult` sheds its now-dead `UnsupportedModel` arm.

The "← current" marker resolves the channel's override first, then the
adapter's own reported selection via `AgentModelCapabilities::reported_current`
(`currentValue`, then `currentModelId`, mirroring the precedence `models()`
uses). Override alone would mark nothing on a plain `!model` — the most common
call — since `desired_model` is `None` until `--model` or a switch sets one.
The override half is read from the agent that actually serves the channel, so
with `--agents N > 1` another slot's pick is never marked as this channel's.

Two `configOptions` spellings are factored into `config_option_id` /
`config_option_label`. Responses carry `id` and `name`
(`SessionConfigOption`), while `configId` is request-only
(`SetSessionConfigOptionRequest`) and `displayName` is pre-standardization;
both spellings are accepted in one place rather than at each read site, so the
catalog listing and the `models` CLI stop resolving labels to `None` on a
conformant adapter.

`post_failure_notice` becomes `post_notice` — it now carries owner command
replies as well as dead-letter warnings.

Known limits, documented rather than half-fixed: `desired_model` is
per-agent-process, so with `--agents N > 1` a later turn on another slot uses
the old model (pre-existing; the desktop picker has the same hole).

Closes block#2543

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
@troyhoffman-oss
troyhoffman-oss requested a review from a team as a code owner July 28, 2026 19:58

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

Copy link
Copy Markdown

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: 5f9a08b987

ℹ️ 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 thread crates/buzz-acp/src/lib.rs Outdated
Comment on lines +2855 to +2858
content
.char_indices()
.find(|&(i, c)| c == '!' && content[..i].ends_with(char::is_whitespace))
.map_or("", |(i, _)| &content[i..])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require commands to immediately follow the leading mention

When an owner-authored message begins with @, this scans all remaining prose for the first whitespace-prefixed bang token. Consequently, an ordinary prompt such as @Agent explain what happens when I use !shutdown is parsed as the exact !shutdown command and terminates the harness; the same pattern can unexpectedly cancel or rotate an active turn. Limit command detection to the command position immediately following the addressed mention rather than searching the entire message.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 29e5af0. Command detection now requires the command at command position — the token immediately following the leading mention. Prose that merely names a command no longer triggers it.

Comment thread crates/buzz-acp/src/lib.rs Outdated
channel_id: Uuid,
model_id: &str,
) -> SwitchOutcome {
if catalog.is_some_and(|caps| !caps.contains(model_id)) {

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 model switches until the catalog is available

During the first turn's session/new, the task is already registered as in flight but ModelCatalog::capture may not have run yet. In that window catalog is None, so even a typo passes this guard, receives a SwitchModel signal, cancels and requeues the active request, and gets the misleading chat reply that the switch is proceeding; the subsequent fresh session merely falls back to the unchanged model. Treat an unavailable catalog as not yet switchable so the documented unknown-ID behavior does not disrupt the first turn.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 8a18553. A missing catalog is no longer treated as "nothing to check" — the pick is deferred instead of passed through, so the first turn is never cancelled on an unvalidated ID. !model replies with the same "I don't know my model list yet" the no-argument branch gives, which is the honest answer at that point. On the observer path it reports as unsupported_model: same meaning for the picker (rejected, session untouched), and the desktop cannot reach the case anyway since its list comes from this same catalog.

Comment on lines +1075 to +1077
let current = pool
.channel_model_override(channel_id)
.or_else(|| caps.reported_current());

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 Preserve the current override while an agent is checked out

After a model override, querying !model while that channel has an active turn cannot find the override because the owning OwnedAgent has been removed from the pool. This falls back to the catalog's first-session reported_current() value and marks the old/default model as current for the entire turn. Keep the per-channel override in state reachable from the main loop or task metadata so busy-channel listings report the model actually in force.

Useful? React with 👍 / 👎.

@troyhoffman-oss troyhoffman-oss Jul 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged, and deferring this one. The inaccuracy is reporting-only and scoped to an in-flight turn: while the agent is checked out, the listing falls back to the adapter-reported current, so the current marker can point at the pre-override model until the turn ends. No switch is lost, the override still applies on the next turn.

Fixing it properly means threading the desired model through task metadata so the listing can read it off the in-flight task, which touches ~19 TaskMeta construction sites. That is more churn than this PR should carry. Tracked as #3535.

An owner-authored message beginning with `@` armed a scan across all
remaining prose for the first whitespace-prefixed bang token. That made
asking an agent *about* a command indistinguishable from issuing it:
`@Agent explain what happens when I use !shutdown` parsed as `!shutdown`
and terminated the harness, and the same shape could cancel or rotate an
active turn mid-sentence.

Commands are now recognized only at command position — first in the
message, or immediately after the run of leading `@mention` tokens.

This deliberately reverses an earlier trade-off in this branch, which
accepted a bang anywhere after a leading mention so that
`@Agent please run !model haiku` would fire. That was too wide: the same
permissiveness is exactly what lets a question execute. `@Agent !model
haiku` still works; the prose form no longer does.

It also drops mentions holding spaces (`@Will Pfleger !rotate`), which
the previous scan tolerated by accident. The parser has no display-name
list, so where such a mention ends is genuinely ambiguous — and any rule
loose enough to end it at `Pfleger` also ends it at the last word of a
sentence. Not firing is the safe side of that ambiguity.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
The pre-cancel guard treated a missing catalog as "nothing to check", so
any pick passed through unvalidated. The only window where the catalog is
missing is the process's first `session/new` — which is already registered
in flight — so in exactly that window a typo cancelled and requeued a live
turn, replied that the switch was proceeding, and then had the fresh
session fall back to the unchanged model.

No catalog now means the pick is deferred rather than passed through. On
the `!model` front door that reads back as the same "I don't know my model
list yet" the no-argument branch already gives, which is the honest answer:
the harness cannot say whether the ID is real.

`CatalogUnavailable` reports as `unsupported_model` on the observer path
rather than adding a status. Both mean "rejected, session untouched" —
all the desktop acts on — and the desktop cannot reach the variant anyway,
since its model list is populated from this same catalog.

Signed-off-by: Troy Hoffman <troy.hoffman@icloud.com>
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.

feat(agents): allow changing an agent's model mid-session (like /model in Claude Code)

1 participant