Skip to content

th-b30a6a: send_message grows an optional skill — the engine resolves it, not the client - #338

Merged
brentrager merged 1 commit into
mainfrom
th-b30a6a-skill-field
Aug 8, 2026
Merged

th-b30a6a: send_message grows an optional skill — the engine resolves it, not the client#338
brentrager merged 1 commit into
mainfrom
th-b30a6a-skill-field

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

Every client resolved a skill itself and prepended the skill's markdown body to the message text. Two costs:

  1. Prose on the wire. The protocol carried a recipe where it should carry an intent.
  2. The skill body got persisted into conversation history, so it was replayed as context on every subsequent turn of that session — growing the context window with instructions that only applied to one turn.

Solution

send_message takes an optional skill name. The server resolves it and composes the body into that turn's system prompt.

{ "action": "send_message", "requestId": "", "sessionId": "", "message": "review my diff", "skill": "code-review" }

The persisted user message stays exactly what the user typed.

The resolver seam

Piece What it is
skills::SkillResolver The host seam — one async fn resolve(&self, name) -> Option<String>. Installed via AppState::with_skill_resolver or LocalServerBuilder::skill_resolver. Big Smooth plugs its own discovery in here.
skills::DirSkillResolver The working default: <root>/<name>/SKILL.md over the :-separated roots in SMOOTH_SKILLS_DIR, first match wins, YAML frontmatter stripped (it's discovery metadata, not instructions).

SMOOTH_SKILLS_DIR unset ⇒ no resolver is installed, so a multi-tenant deploy never serves host skills by accident.

Fail-closed, unlike images

An unresolvable skill returns error { code: "SKILL_NOT_FOUND" } and the turn does not run. Answering without the requested recipe is indistinguishable to the caller from answering with it, so degrading silently is worse than erroring.

Names are [A-Za-z0-9_-]{1,128} — path traversal is unrepresentable rather than filtered, since the name is joined onto a filesystem root.

Backward compatibility

Absent skill is byte-for-byte the previous behavior. TurnRequest::skill_section defaults to None, which appends no system-prompt section.

Tests

rust/smooth-operator-server/tests/skill_field.rs drives the real handler::handle_frame against in-memory storage and MockLlmClient — fully offline, and it is the contract the polyglot servers will mirror:

  • skill body lands in the system prompt, frontmatter stripped, and the persisted user message is untouched
  • unknown skill ⇒ SKILL_NOT_FOUND, mock.call_count() == 0 (the turn never ran)
  • a traversal name (../greet) resolves nothing
  • no resolver installed ⇒ every skill is unknown
  • absent skill ⇒ ordinary turn, no ## Skill: in the prompt
  • whitespace-only skill ⇒ treated as absent

Plus unit tests in skills.rs for name validation, frontmatter stripping (including a --- markdown rule mid-body and unterminated frontmatter), root precedence, and path-list parsing.

Local gates (this repo's rust/** PR CI is compile-gate only, so these are the real gate):

  • cargo test -p smooai-smooth-operator-server --lib --tests276 passed, 33 suites
  • cargo clippy -p smooai-smooth-operator-server --all-targets0 errors (10 warnings, all pre-existing on main)
  • cargo fmt --all clean

Docs + spec

  • spec/actions/send-message.schema.json — the field, its pattern, and the fail-closed contract
  • docs/Reference/Protocol Reference.md — new "Skills on send_message" section

Deferred: polyglot parity

The TS / Python / Go / .NET servers ignore skill (it degrades to an ordinary turn) — the same staging images[] is in today, where Rust is the reference implementation and the ports follow separately. Worth a follow-up pearl once the seam shape has settled here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YSE8xAs9aWN5VhrnnSyLKq

…es it, not the client

Every client used to resolve a skill itself and prepend the markdown body to
the message text. That put prose on the wire and persisted the skill body into
conversation history, where it was replayed as context on every later turn.

The wire now carries intent (`skill: "code-review"`); the server resolves the
name and composes the body into THAT turn's system prompt, so the persisted
user message stays exactly what the user typed.

- `skills::SkillResolver` — the host seam (`AppState::with_skill_resolver` /
  `LocalServerBuilder::skill_resolver`).
- `skills::DirSkillResolver` — the default: `<root>/<name>/SKILL.md` over the
  `:`-separated roots in `SMOOTH_SKILLS_DIR`, first match wins, frontmatter
  stripped. Unset ⇒ no resolver installed, so a multi-tenant deploy never
  serves host skills by accident.
- Fail-closed, unlike `images`: an unresolvable skill is `SKILL_NOT_FOUND` and
  the turn does not run — answering unskilled is indistinguishable to the
  caller from answering skilled. Names are `[A-Za-z0-9_-]{1,128}`, which makes
  path traversal unrepresentable rather than filtered.

Absent `skill` is byte-for-byte the previous behavior. Rust is the reference
implementation; the TS/Python/Go/.NET servers ignore the field for now (the
same staging `images` is in).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSE8xAs9aWN5VhrnnSyLKq
@changeset-bot

changeset-bot Bot commented Aug 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b0970d4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Minor
@smooai/smooth-operator-web-chat-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit cd9b686 into main Aug 8, 2026
7 checks passed
brentrager added a commit that referenced this pull request Aug 14, 2026
…ver-side, Rust #338 parity (#352)

The C# server carried the generated SendMessageRequest.Skill field but ignored
it, exactly as the TS/Python/Go servers still do. It now resolves the skill and
composes it into the turn.

- Skills: IsValidSkillName / StripFrontmatter / SkillSection / ResolveSectionAsync
- ISkillResolver host seam on the FrameDispatcher ctor (analog of Rust
  AppState::with_skill_resolver); DirSkillResolver over SMOOTH_SKILLS_DIR, with
  the ASP.NET host preferring a DI-registered resolver and falling back to
  FromEnv() like Rust's install_skill_resolver_from_env.
- Fail-CLOSED: an unresolvable skill is SKILL_NOT_FOUND and the turn does not
  run — a caller who asked for a recipe and silently got a freeform answer has
  no way to tell. Blank skill is treated as absent (Rust trims then filters).
- The body lands in the SYSTEM PROMPT, appended last, so the persisted user
  message stays what the user typed and skill prose never accumulates in
  history to be replayed every later turn.

Name validation makes traversal unrepresentable rather than filtered, so
"../../etc/passwd" can never reach a Path.Combine.

Tests: all five Rust skills.rs unit tests ported under their Rust names, plus
dispatcher-level fail-closed / placement / blank / absent coverage.
RecordingChatClient promoted out of FileTransferTests into a shared
TestChatClients.cs rather than duplicated. 311/311 server tests green,
0 warnings.

Source-only — the engine stays the published NuGet, so no publish gate.


Claude-Session: https://claude.ai/code/session_012iM1Q8JC1H83H2FXQVQNs9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
brentrager added a commit that referenced this pull request Aug 14, 2026
…side, Rust #338 parity (#357)

Second language in the fan-out after .NET (#352). The TS server had the field on
the wire and ignored it, exactly as its own changelog admitted.

- skills.ts: isValidSkillName / stripFrontmatter / skillSection / resolveSection
- SkillResolver seam via serve({ skillResolver }); DirSkillResolver over
  SMOOTH_SKILLS_DIR with explicit-wins-then-env, mirroring Rust's
  install_skill_resolver_from_env
- Fail-CLOSED, and resolved BEFORE the 202 ack so a client never gets
  "accepted" for a turn that will never run
- Body appended LAST to the system prompt; the persisted user message stays
  exactly what the user typed

Name validation makes traversal unrepresentable rather than filtered.

Changeset names BOTH the TS package and the lockstep anchor, per #346 — the
omission of the anchor is what stranded the .NET work in #348/#352.

Tests: five Rust skills.rs tests ported under their Rust names + over-the-socket
fail-closed / placement / blank-as-absent. 254 green (245 baseline + 9).


Claude-Session: https://claude.ai/code/session_012iM1Q8JC1H83H2FXQVQNs9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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.

1 participant