Skip to content

th-ebe27d: resolve send_message.skill in the Python and Go servers - #560

Merged
brentrager merged 2 commits into
mainfrom
th-e10b6e-skills
Aug 30, 2026
Merged

brentrager merged 2 commits into
mainfrom
th-e10b6e-skills

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

Rust #338 moved skills from prose on the wire to intent on the wire: the client sends skill: "code-review", the server resolves the name to its markdown body and composes it into the turn's system prompt. The persisted user message stays exactly what the user typed, instead of carrying a skill body that then gets replayed as context on every later turn.

Rust, C# (#352) and TypeScript (#357) carried it. The Python and Go servers ignored the field outright — which is worse than not supporting it: a client that asked for a skill got a confident unskilled answer with no signal that anything was dropped.

spec/actions/send-message.schema.json has declared the field since #338, so this was a spec the two servers silently did not honor.

What changed

Both now mirror rust/smooth-operator-server/src/skills.rs:

  • SkillResolver host seamServerState.skill_resolver (Python) / WithSkillResolver (Go).
  • DirSkillResolver default — reads <root>/<name>/SKILL.md over the :-separated roots in SMOOTH_SKILLS_DIR, first root wins. Unset ⇒ no resolver installed ⇒ any skill field is a clean SKILL_NOT_FOUND, so a multi-tenant deploy never serves host skills by accident.
  • Frontmatter is stripped (it is discovery metadata, not instructions); a frontmatter-only file is not a skill.
  • The section is appended last to the assembled system prompt, so an invoked skill outranks the agent's standing configuration.

Two properties are load-bearing and tested as such:

Fail closed, before the ack. An unresolvable skill emits SKILL_NOT_FOUND instead of the 202 and never starts a turn. Resolving after the ack would leave the client holding an accepted turn that then errors. In Go this meant lifting the resolution above assembleSystemPrompt, not merely above the ack — the prompt is assembled first there. A blank/whitespace skill is "no skill", not an unknown one, so a client that always sends the field still works.

Traversal is unrepresentable, not filtered. [A-Za-z0-9_-]{1,128} — the pattern the schema already declared — is enforced before the name is ever joined onto a filesystem root, so ../../etc/passwd, a/b, a\b and an embedded NUL cannot round-trip into a path.

The corpus is now the oracle

New scenario spec/conformance/scenarios/skill-unknown-error.json pins the fail-closed contract across all five servers. It needs no filesystem setup (the default server installs no resolver), so this seam is held by the shared corpus rather than five per-language opinions about it.

oracle result
Rust scenario_parity
C# ScenarioParityTests ✅ 20
Python test_scenario_parity.py ✅ 19
TypeScript scenario-parity.test.ts ✅ 19
Go TestScenarioParity ✅ 19

Verification

  • Python: 13 new tests, full suite 404 passed.
  • Go: 12 new tests, full go/server suite green.
  • Both new suites mutation-checked — disabling the prompt append fails them (assert '## Skill: review' in "You are a helpful..." / system prompt missing the skill section), so they are not vacuous.
  • The first five tests in each file mirror the Rust unit tests case for case, under their Rust names.

One Go-specific hazard pinned by test: the env install checks the concrete *DirSkillResolver for nil before assigning into the SkillResolver interface. A typed nil would make ResolveSkillSection's resolver == nil guard miss and panic instead of reporting SKILL_NOT_FOUND.

Notes

🤖 Generated with Claude Code

Rust #338 moved skills from prose-on-the-wire to intent-on-the-wire: the client
sends `skill: "code-review"`, the SERVER resolves the name to its markdown body
and composes it into the turn's system prompt, so the persisted user message
stays exactly what the user typed instead of carrying a skill body that gets
replayed as context on every later turn.

Rust, C# and TypeScript carried it. Python and Go ignored the field outright —
worse than not supporting it, because a client that asked for a skill got a
confident UNSKILLED answer with no signal anything was dropped.

Both now mirror rust/smooth-operator-server/src/skills.rs: a SkillResolver host
seam (ServerState.skill_resolver / WithSkillResolver) plus a DirSkillResolver
default over <root>/<name>/SKILL.md across the ':'-separated SMOOTH_SKILLS_DIR
roots, first root wins. Unset => no resolver installed => any skill field is a
clean SKILL_NOT_FOUND, so a multi-tenant deploy never serves host skills by
accident.

Two properties are load-bearing, and tested as such:

- Fail closed, BEFORE the ack. An unresolvable skill emits SKILL_NOT_FOUND
  instead of the 202 and never starts a turn. (Go needed the resolution lifted
  above assembleSystemPrompt, not merely above the ack, since the prompt is
  assembled first there.) A blank/whitespace skill is "no skill", not an unknown
  one, so a client that always sends the field still works.
- Traversal is unrepresentable, not filtered. [A-Za-z0-9_-]{1,128} — the pattern
  spec/actions/send-message.schema.json already declared — is enforced before the
  name is ever joined onto a filesystem root.

New conformance scenario skill-unknown-error pins the fail-closed contract across
all five servers. It needs no filesystem setup (the default installs no
resolver), so the corpus is the oracle for this seam rather than five
per-language opinions about it.

Go's env install checks the concrete *DirSkillResolver for nil before assigning
into the interface: a typed nil would make ResolveSkillSection's `resolver ==
nil` guard miss and panic instead of reporting SKILL_NOT_FOUND. Pinned by test.

Verified: 13 new Python tests + 404 total green; 12 new Go tests + full suite
green; parity oracle 19/19 in Python, Go and TypeScript. Both new suites
mutation-checked — disabling the prompt append fails them.

No wire-protocol change; the schema already carried the field.
@changeset-bot

changeset-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 147e6d5

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 Patch
@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

The lint lane sorts `from .session_store` before `from .skills`; my new export
block landed between server and session_store.
@brentrager
brentrager merged commit 55bf1ca into main Aug 30, 2026
8 checks passed
@brentrager
brentrager deleted the th-e10b6e-skills branch September 6, 2026 16:37
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