Skip to content

fix(agents): an @-mention binds the conversation instead of borrowing one turn - #1115

Merged
philmerrell merged 1 commit into
developfrom
feature/agent-mention-binds-conversation
Sep 15, 2026
Merged

philmerrell merged 1 commit into
developfrom
feature/agent-mention-binds-conversation

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

The bug

Mentioning an Agent ran that turn as the Agent and silently reverted the next one. The thread still looked like the Agent's while its tools, skills and model were gone, and nothing surfaced the change — not the UI, and not the model, which cannot know its own toolset shrank.

Found from a real dev session: the Rubric Builder drafted a rubric, the user said "publish it", and create_rubric came back Unknown tool: create_rubric. The model then told the user to toggle that tool off and on in the picker — a confident wrong diagnosis sending them to fix a setting that was already correct. The catalog entry, the Agent's binding and the live MCP server were all fine; the turn simply had no Agent.

The tell is content-free, on the session's C# rows:

turn turnAgentId
1 ast-9149ef191614
2+ None (with agentSwitched=True on the first)

Why reverse D11

D11 chose the per-turn borrow deliberately, so this reverses a decision rather than repairing an oversight. What changed is the evidence — measured on prod sessions-metadata:

prod dev
Mention sessions 247 61
…where the mention started the conversation 247 60
…mid-thread-only mention 0 1
Mentioning a different Agent inside a bound thread 0 0
Threads that continued and silently lost the Agent 4 8

The borrow was paying an invisible failure mode for a case that has never occurred.

What changes

A mention now means "talk to this Agent", with two outcomes and no third:

Thread state Behaviour
No messages yet The Agent binds the conversation, exactly like launching it from its card
Already has messages The message opens a new conversation with that Agent, and the SPA says so

Persisting preferences.assistant_id is necessary but not sufficient, which is the easy part to miss: every turn's Agent is resolved from the request, the SPA's only carrier is the assistantId query param, and the self-heal effect that refills it from preferences runs on session load. So the SPA sets the param and stops sending agent_mention entirely.

The backend still honours that flag for clients predating this change, and binds_conversation gains thread_is_empty so a stale tab mentioning into a fresh thread lands where a current one does. Its thread lookup runs only for mention turns, so no bound-Agent turn pays a query it cannot act on.

Also fixed: "Continue" after a truncation

Same invisible loss, on the path users are told to use. continue_truncated skipped the whole assistant block, so a properly launched Agent finished its reply with none of its tools, skills, model or instructions — spec-acknowledged as a known edge. The SPA was already resending rag_assistant_id there (continueTruncatedTurn's comment says "so the backend rebuilds the same model/tools/assistant agent"); only that guard discarded it. The block now runs for a continuation, with binding validation and persistence skipped (it binds nothing new) and RAG skipped (empty message → a KB search would spend a query on "").

⚠️ A resume still skips the block, and always did keep its tools — it rebuilds from PausedTurnSnapshot, replaying the original turn's exact enabled_tools / system_prompt / enabled_skills to reconstruct the same prompt-cache key. Worth stating because resume rows carry no turnAgentId, so a census of "turns with no Agent" reads them as losses and overcounts badly (I overcounted 4,127 prod calls that way before noticing the field only exists since 2026-08).

Cost

Two known costs retire with the borrow: the ~$0.12-per-mention prefix re-write (a bound conversation swaps once and stays instead of swapping back), and the history fork, where the mention agent and the plain agent were two cached instances that never saw each other's turns.

Testing

  • Backend 8,585 passed / 3 skipped; frontend 3,024 passed across 250 files; tsc --noEmit clean
  • New: 11 policy cases (backend), 9 routing cases (frontend)
  • The rule lives in one testable place at each end — mention-routing.ts and agent_binding_policy.py — following the system_prompt_resolver precedent, and the two mirror each other

⚠️ Not exercised in a browser. This worktree's code is not what the local stack serves. To verify in dev: @-mention an Agent as the first message, then send an ordinary follow-up and confirm it still reaches the Agent's tools — that is the exact sequence that failed.

Spec: docs/specs/agent-marketplace.md (D11 + Phase 7 notes)

🤖 Generated with Claude Code

… one turn

Mentioning an Agent ran that turn as the Agent and silently reverted the next
one. The thread still looked like the Agent's while its tools, skills and model
were gone, and nothing surfaced the change — not the UI, and not the model,
which cannot know its own toolset shrank. Asked to use a tool it had used a
moment earlier it got `Unknown tool: create_rubric`, and told the user to toggle
that tool in the picker: a confident wrong diagnosis sending them to fix a
setting that was already correct.

D11 chose the per-turn reading deliberately, so this reverses a decision rather
than repairing an oversight. What changed is the evidence. Measured on prod
sessions-metadata (`turnAgentId` on the `C#` rows): of **247 mentions, 247
started the conversation**. Zero were mid-thread consults; zero mentioned a
second Agent inside a thread bound to a first. (Dev: 60 of 61.) The borrow was
paying an invisible failure mode for a case that has never occurred.

A mention now *means* "talk to this Agent", with two outcomes and no third:

  * empty thread   -> the Agent binds the conversation, exactly like launching
                      it from its card. Safe because there is no history for the
                      binding to misrepresent.
  * has messages   -> the message opens a NEW conversation with that Agent, and
                      the SPA says so. The Agent cannot be bound to history
                      written under other instructions, and must not be borrowed.

Persisting `preferences.assistant_id` is necessary but NOT sufficient, which is
the part that is easy to miss: every turn's Agent is resolved from the request,
the SPA's only carrier is the `assistantId` query param, and the self-heal
effect that refills it from preferences runs on session *load*. So the SPA now
sets the param and stops sending `agent_mention` at all. The backend still
honours that flag for clients that predate this change, and `binds_conversation`
gains `thread_is_empty` so a stale tab mentioning into a fresh thread lands
where a current one does. Its thread lookup runs only for mention turns, so no
bound-Agent turn pays a query it cannot act on.

Also fixed, because it is the same invisible loss on the path users are *told*
to use: "Continue" after a max_tokens truncation skipped the whole assistant
block, so a properly launched Agent finished its reply with none of its tools,
skills, model or instructions (spec-acknowledged as a known edge). The SPA was
already resending `rag_assistant_id` there — `continueTruncatedTurn`'s own
comment says "so the backend rebuilds the same model/tools/assistant agent" —
and only the `not is_continuation` guard discarded it. The block now runs for a
continuation, with binding validation and persistence skipped (it binds nothing
new) and RAG skipped (the turn carries an empty message, so a KB search would
spend a query on "" and augment nothing).

A resume still skips the block and always did keep its tools: it rebuilds from
`PausedTurnSnapshot`, replaying the original turn's exact enabled_tools /
system_prompt / enabled_skills to reconstruct the same prompt-cache key.
Re-resolving there would risk a different effective set and orphan the paused
agent. Worth stating because resume rows carry no `turnAgentId`, so a census of
"turns with no Agent" reads them as losses and overcounts badly.

Two known costs retire with the borrow: the ~$0.12-per-mention prefix re-write
(a bound conversation swaps once and stays instead of swapping back), and the
history fork, where the mention agent and the plain agent were two cached
instances that never saw each other's turns.

The rule lives in one testable place at each end — `mention-routing.ts` on the
client, `agent_binding_policy.py` on the server — following the existing
`system_prompt_resolver` precedent: the rule is a handful of lines, the code
around it is a thousand, and a rule no test can reach is a rule that drifts.

Kept as one commit: the mention and continuation halves edit the same guards on
the same block, and splitting them would mean a first commit that knowingly
leaves the block wrong.

Backend 8585 passed / 3 skipped; frontend 3024 passed across 250 files; tsc
clean. Not exercised in a browser — this worktree's code is not what the local
stack serves, and that stack is down.

Spec: docs/specs/agent-marketplace.md (D11 + Phase 7 notes)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 9eeee27 into develop Sep 15, 2026
6 checks passed
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