Files
deepseek-harness/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md
T
Yichen Jiang 31ed85900d fix(client): review fixes for invocation rendering and turn boundaries
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.
2026-08-08 11:30:16 +08:00

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 shared renderSkillContent, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new skill-invocation MessageSource kind ({ name, args? }) before starting a turn through the same route-served gate as session.prompt.
  • renderSkillContent moved from dsh-tool-skill to the dsh-skill seam: the skill tool 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.list serves every user-invocable skill and carries modelInvocable, so the browser menu lists user-only skills with a marker (description prefix — the hint field is claim-state ghost text the menu never renders).
  • ui-skill claims a menu pick or an entered /name [args] into the invoke transaction (matchEnter strong-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-invocation node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: /name chip, 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 $name core 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 + skill tool) 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-invocation source rides user/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.invoke later 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 skill tool 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.