th-ebe27d: resolve send_message.skill in the Python and Go servers - #560
Merged
Merged
Conversation
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 detectedLatest commit: 147e6d5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonhas 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:SkillResolverhost seam —ServerState.skill_resolver(Python) /WithSkillResolver(Go).DirSkillResolverdefault — reads<root>/<name>/SKILL.mdover the:-separated roots inSMOOTH_SKILLS_DIR, first root wins. Unset ⇒ no resolver installed ⇒ anyskillfield is a cleanSKILL_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_FOUNDinstead 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 aboveassembleSystemPrompt, not merely above the ack — the prompt is assembled first there. A blank/whitespaceskillis "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\band an embedded NUL cannot round-trip into a path.The corpus is now the oracle
New scenario
spec/conformance/scenarios/skill-unknown-error.jsonpins 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.scenario_parityScenarioParityTeststest_scenario_parity.pyscenario-parity.test.tsTestScenarioParityVerification
go/serversuite green.assert '## Skill: review' in "You are a helpful..."/system prompt missing the skill section), so they are not vacuous.One Go-specific hazard pinned by test: the env install checks the concrete
*DirSkillResolverfor nil before assigning into theSkillResolverinterface. A typed nil would makeResolveSkillSection'sresolver == nilguard miss and panic instead of reportingSKILL_NOT_FOUND.Notes
@smooai/smooth-operator(per the anchor guard: fail a PR that changes a lockstep-stamped tree without naming the anchor #356 guard), so every artifact republishes together.memory_for_accessth-374b27: memory_for_access seam — durable auto-recall into every turn #330, MCP client th-2b6bf1: MCP client — configured MCP servers surface as engine tools #340) are engine-layer and untouched here.🤖 Generated with Claude Code