The user-turn predicate (opensUserTurn) unifies the three parallel consumers a new node kind silently missed — produced-files turn reset, retry liveness, own-words force-scroll — so a skill invocation behaves as the turn opener it is. The menu marker resolves through ctx.locale.bind instead of a hand-rolled snapshot lookup; the dead legacy <skill> render arm goes with the removal cut; command-over-skill name precedence is now documented at the matchEnter seam; and the emptied replacement catalog keeps the no-reload sentence, with the never-published residual recorded in the Agent Note.
5.3 KiB
Agent Note: User-explicit skill invocation over skill.invoke
Status: implemented
English | 中文
Problem
A disable-model-invocation: true skill is user-only by design: it never enters the model-facing catalog and the skill tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. skill.list filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered /name line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to read-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee.
Decision
User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill:
skill.invoke { sessionId, name, text? }(host apiproxy) enforces user-invocation policy at the operation boundary (skill-not-found/skill-not-invocable), renders the skill with the sharedrenderSkillContent, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the newskill-invocationMessageSourcekind ({ name, args? }) before starting a turn through the same route-served gate assession.prompt.renderSkillContentmoved fromdsh-tool-skillto thedsh-skillseam: theskilltool result and the injection share one verbatim<skill_content>shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool.skill.listserves every user-invocable skill and carriesmodelInvocable, so the browser menu lists user-only skills with a marker (description prefix — thehintfield is claim-state ghost text the menu never renders).- ui-skill claims a menu pick or an entered
/name [args]into the invoke transaction (matchEnterstrong-waits the catalog; unknown names stay plain prompts). The unreached legacy<skill>name</skill>reference codec is removed. - The transcript materializes the injection as a dedicated
skill-invocationnode from source metadata (never re-parsed from the body) and renders a right-aligned bubble:/namechip, trailing text, and the injected block collapsed behind a disclosure.
Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto MessageSource and the catalog sentence.
Alternatives considered
agent.inject()context injection — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected.- A host
/skill <name>command (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. - Client-side expansion (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected.
- Host prompt-pipeline scanning for
/name(Codex$namecore mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. - Per-injection preamble line (Kimi's
User activated the skill …) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result.
Consequences
- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog +
skilltool) is unchanged. - Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays.
- The
skill-invocationsource ridesuser/message, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. - TUI and ACP can adopt
skill.invokelater for the same semantics; until then the TUI's client-side expansion remains its own path. - Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the
skilltool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error.