Skip to content

feat(composer): route @agent mentions to the Task tool - #1101

Open
f4ct0r wants to merge 9 commits into
vastsa:mainfrom
f4ct0r:feat/composer-agent-mention
Open

f4ct0r wants to merge 9 commits into
vastsa:mainfrom
f4ct0r:feat/composer-agent-mention

Conversation

@f4ct0r

@f4ct0r f4ct0r commented Sep 26, 2026

Copy link
Copy Markdown

Summary

Let a user name a subagent directly, with @agent in the composer, and route the turn to that delegate (#986).

Before this, a subagent was reachable only when the model chose to call Task. Work the user already knows is separable — "ask the explorer where the retry policy lives" — had no entry point. The delegation machinery was complete and already had one implementation: the cards, the topology, the permission queue, TaskWait and the context budget all key off "this turn contains a Task call", and none of them asks who initiated it. This adds the missing door rather than a second path.

The composer offers the same catalog Task is built from, and at send time main rewrites the typed draft into an explicit Task instruction while the user's own words stay in the transcript as a chip — the arrangement /skill already uses (ADR 0024).

How it reads

@explorer 帮我查找一下是否存在沙箱
  • The @ menu gains an Agents group beside the file rows, each with its own heading and the bot badge used by the subagent settings and the model pickers.
  • Accepting one creates the same atomic inline chip a completed file reference gets: it displays and deletes as one thing, so a keystroke cannot cut @explorer into @explo.
  • In the transcript the named delegate is chipped too, and the text around it stays text.

Decisions worth reviewing

  • @ rather than / for the trigger. @ already means "reference an entity", and both mentions serialize to an @token.
  • A file of the same name wins. A token is an agent only when it names a delegate and the workspace holds no file of that name, so a project that really has an explorer file gets the file.
  • Agent mode only. Task is registered only there (ADR 0062 §4); Plan and Goal omit the group and refuse a hand-typed mention rather than letting the model answer a request the user meant to delegate.
  • A strong prompt, not an enforced dispatch — the identical trade-off /skill accepts. Enforced dispatch needs a public runtime entry point for a private tool, a new RPC, and a synthetic parent row, and would hand the parent a report it never asked for. Recorded as out of scope in ADR 0308.

Two defects found while building it

Both are covered by tests that fail without the fix:

  1. A mention mid-sentence never delegated. The chip serialized to look@explorer, and the send-time resolver deliberately reads an @token only at a start or after whitespace (that is what stops user@host reading as a mention). The delegation silently did not happen. The agent branch now emits a leading space; file output is byte-for-byte unchanged and a test pins that.
  2. Stopping a resent turn put a model instruction in the composer. The smart-stop recovery read the transcript row's content — what the model was given — so after send → stop → edit-and-resend → stop the composer came back holding Call the \Task` tool with the agent below…. The typed text lives in command. This predates the mention work (reachable via /templateand/skillsince ADR 0024), but an@agent` mention rewrites every turn, so it stopped being an edge case.

Verification

Check Result
pnpm build:js, pnpm --filter @pi-desktop/desktop typecheck pass
pnpm lint (biome + style tokens) pass
pnpm -r --if-present test desktop 2978, shared 1132, i18n 27, docs 11
cargo test -p host-core --locked 654 passed, 0 failed
cargo fmt --check, cargo clippy -p host-core --all-targets pass / no errors
pnpm check:agent-policy, pnpm check:pr-base pass
pnpm test:e2e:composer-autocomplete 6/6 (real Chromium; extended to cover the Agents group)
pnpm test:e2e:subagents 34/34
pnpm test:e2e:boot pass

The remaining desktop failures are the same timing/network-sensitive tests that fail on origin/main in this environment; they are not touched by this branch.

Notes for the reviewer

  • host-core gains one optional field, agentMentions, mapped beside the existing skillMentions in ui_to_record / record_to_ui. Old rows read back with no field and render exactly as before.
  • Three transcript entry points still start a turn without a local draft snapshot (editUserMessage, retryLastPrompt, regenerate). The recovery now reads command, so it is correct, but retryLastPrompt re-sends the stored rewritten text as a fresh prompt — a separate pre-existing issue I deliberately left alone rather than widening this change.
  • @agent is not persisted as a distinct transcript field on the Rust side beyond the offsets; the durable record is the command text plus agentMentions.

Closes #986

A user could reach a subagent only when the model chose to call `Task`,
so work the user already knows is separable had no entry point. The
delegation machinery is complete and already has one implementation —
cards, topology, the permission queue, TaskWait and the context budget
all key off "this turn contains a Task call", and none of it asks who
initiated it. This adds the missing door rather than a second path: the
composer offers the same catalog `Task` is built from, and main rewrites
the typed draft into an explicit Task instruction while the user's own
text stays in the transcript as a chip, exactly as /skill already works.

`@` is extended rather than `/`, because `@` already means "reference an
entity" and both mentions serialize to an `@token` on the wire. That
collision is resolved by rule, not by UI: a token is an agent only when
it names a delegate and the workspace holds no file of that name, so a
user who really has an `explorer` file gets the file. Since Task exists
in Agent mode only, Plan and Goal omit the group and refuse a hand-typed
mention instead of letting the model answer a request the user meant to
delegate.

The rewrite is a strong prompt, not an enforced dispatch, the same
trade-off /skill accepts. Enforced dispatch would need a public runtime
entry point for a private tool, a new RPC, and a synthetic parent row,
and would hand the parent a report it never asked for.

fixes vastsa#986
The composer autocomplete probe only ever built command and file rows,
so the new delegate rows would have shipped with no layout coverage at
all: a description that overflows its row, or a menu that stopped
labelling its group, would have passed CI silently.

The probe now renders a third mode — "@" with the Agents group above a
file row — and asserts what a user actually depends on there: the exact
@token, a description that truncates instead of stretching the row, the
agents ordered ahead of the file, and the group label being present.
File-mode assertions now address the file row by its actual index
rather than assuming it is first, which is what the group heading
changed.

fixes vastsa#986
The Agents group label was the only heading the "@" menu rendered, so a
menu holding both kinds showed the delegates and then the file rows
underneath that same label. The rows were still correct — a file row was
never a delegate, and accept() still inserted the path — but the menu
read as one mixed section, which is the one thing a user cannot be left
to guess at when the two things are opposite in meaning.

Both sections are now headed, "Agents" and "Files", in the nine shipped
catalogs. Slash mode is untouched: its five command kinds keep their own
labels and order.

The layout probe now counts the headings and pins their order, which
turned up a latent flaw in the probe itself: it reused one React root
across the three modes, so the previous mode's heading stayed in the
document and was counted against the current menu. Each pass now gets a
fresh root.

fixes vastsa#986
A delegate row wore a branch glyph, which reads as "fork" or "diff" — the
icon the composer already uses for anything shaped like a tree, and not
what anyone would guess a subagent looks like. The app already has the
right mark: the bot badge the subagent settings and the model pickers
use for the same concept, so the same delegate reads the same way
everywhere.

Only the glyph changed. The badge sits in the shared composer-ac-icon
slot that file and command rows already occupy, so it inherits their
colour and sizing and no CSS was needed; the two group headings, their
order, and every other row are untouched.

Both rows are labelled for assistive tech, so the icon is what a sighted
user reads, and the tests now pin the glyph on each kind. The first
assertion I wrote compared aria-hidden, which turned out to be set on
both rows by lucide and so distinguished nothing.

fixes vastsa#986
Picking a delegate left `@explorer` as ordinary text in the draft. It
read as a string the user could take apart, and one keystroke could: a
caret inside the token plus a Backspace gives `@explo`, which is both a
broken mention and a half-typed handle the resolver would not accept.
A delegate is one thing the user picked, so it now becomes the same
inline chip a completed file reference becomes, badge and all. That
chip is `contentEditable=false`, which is what makes deletion atomic —
the caret cannot enter it, so a delete takes the whole mention.

Building it surfaced a bug that would have shipped quietly. The chip
serializes back to `@name`, and the send-time resolver only reads an
`@token` at a start or after whitespace — that rule is what stops
`user@host` from reading as a mention. So `look@explorer` was produced
for a mention sitting mid-sentence, and the resolver ignored it: the
delegation silently did not happen. The agent branch now emits a
leading space when text precedes it. File output is byte-for-byte
unchanged, and a test pins that so the two never drift together.

A delegate's `path` is a `Task` handle, not a location, so `kind` is the
discriminant that keeps one off the attachment path in the optimistic
transcript row and the prompt builder, and off the image path on draft
restore — none of which may receive it and ask the host to read it. A
draft restore brings a delegate back as a delegate rather than
restyling it as a file chip.

fixes vastsa#986
The composer chip was never the whole story. A sent turn showed its
typed form as one `chat-command-chip` around the entire draft, so a
delegate the user deliberately named read as `@explorer 帮我查找…` — the
same weight as the words around it, and nothing like the file reference
sitting next to it in the same transcript.

Skill invocations already solved this and are the model to follow: the
offsets of the tokens the user named are recorded on the message and
persisted, and the transcript chips exactly those. So `agentMentions`
does the same, through `ui_to_record` / `record_to_ui` beside
`skillMentions`.

Recording at send time rather than re-resolving on render is what makes
this durable. Re-resolving would mean a message losing its chip the day
its delegate was removed, and would need the delegation catalog fetched
into the transcript. Recorded, a message sent while a delegate existed
keeps its chip forever, the same way a Skill chip does today.

The chip reuses the `.composer-chip` surface a file reference uses, with
the bot badge, and deliberately is not a button: a file chip opens that
file, and a delegate names a run, which the transcript already shows
above. A stored range that does not line up with the text falls back to
the whole draft, so a bad offset can never drop or duplicate characters.

fixes vastsa#986
The chip repeated the `@` the user typed, which made a chip read as
"@explorer" while the file chip beside it read as "AGENTS.md" — same row,
two different weights of token, and the sigil crowded a badge that
already says what the thing is. It shows `explorer` now.

Scoped to the transcript on purpose. The composer chip and the `@` menu
row are the token being chosen or typed, so they keep the `@`; dropping
it everywhere would make the menu disagree with the draft. The chip is
display only: the stored offsets index the typed form, which still
carries the `@` and still reaches the model, and prose that merely
contains `@explorer` is untouched text. A test pins each of those so the
scope cannot quietly widen.

Also fills in the transcript section of the English component spec, which
an earlier edit of mine had failed to write — the script asserted the
Chinese anchor first and exited before saving.

fixes vastsa#986
Send a turn, stop it, edit the message and resend, stop again: the
composer came back holding

  Call the `Task` tool with the agent below before answering…

The transcript row's `content` is what the MODEL was given, and for a
template, a Skill or an `@agent` mention that is the expanded prompt. The
words the user typed live in `command`. The smart-stop recovery read
`content` and put that into the composer, so the next send would have
carried an instruction addressed to the model back to the model.

It only showed up for a resent turn because an ordinary send keeps a
renderer-side snapshot, which the recovery prefers. An edit-and-resend
records no snapshot, so it fell through to the transcript — and the spec
already says the recovery is meant to restore the snapshot "instead of
copying serialized message paths back into the textarea", which is the
one thing this branch did.

Read `command` first and fall back to `content`, which is still correct
for an ordinary prompt that was never rewritten. The bug predates the
mention work — it has been reachable through `/template` and `/skill`
since ADR 0024 — but an `@agent` mention rewrites every such turn, so it
stopped being an edge case.

Left alone deliberately: the three transcript entry points that start a
turn without a snapshot. `retryLastPrompt` routes through
`editUserMessage` with the stored rewritten text, so snapshotting there
would restore that rewrite instead — moving the defect rather than
fixing it. Worth its own change.

fixes vastsa#986
Upstream moved since this branch started: ten commits, including an
architecture refactor that extracted the store transform helpers into
`stores/helpers/store-helpers.ts`. Merged rather than rebased so no
history rewrite is needed on a branch that is already pushed.

One conflict, in `app-store.ts`, and it is the file this branch already
touched. The resolution takes upstream's side for the moved helpers and
carries this branch's delegate guard to its new home:
`promptAttachmentsFromDraft` no longer turns an `@agent` mention into an
attachment, wherever it now lives. The one test asserting that guard
followed the move.

Re-validated on the merged result: build, typecheck, lint, desktop 2979,
host-core 654, and the composer-autocomplete and subagents E2E suites.

This branch has not been deployed

No deployments
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.

[Feature] 希望可以通过@agent来调用指定agent

1 participant