Skip to content

feat: skills — SKILL.md files loaded on demand (#252) - #302

Merged
regnull merged 5 commits into
mainfrom
feat/skills-load-252
Oct 18, 2026
Merged

regnull merged 5 commits into
mainfrom
feat/skills-load-252

Conversation

@regnull

@regnull regnull commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Skills: SKILL.md files loaded on demand (issue #252)

Ports OpenCode's skill mechanism to Python (OpenCode is MIT-licensed — credited in the
docstrings of backend/openbot/skills.py and backend/openbot/tools/builtin/skills.py).

How it works

  • Discovery — discover_skills() walks skills/**/SKILL.md (or skills/<id>.md) under the
    workspace root and OpenBot's own state directory (.openbot/skills), skipping junk dirs
    (.git, node_modules, __pycache__, …), capped at 64 skills. Workspace skills shadow same-id
    skills from the state directory.
  • Front matter — a skill needs YAML front matter with name and description; malformed
    front matter, bad ids (letter/digit-start, ≤64 chars) or overlong fields make a skill unusable
    (SkillError) but never break the others. Multiline YAML scalars are folded into one line.
  • Prompt — only ids and descriptions enter the system prompt (a # Skills section), via the
    existing build_system_prompt_parts machinery; a bot with no skills costs nothing.
  • load_skill(name) tool — returns the skill's body inside an OpenCode-style
    <skill_content> envelope plus the skill's base directory and its sibling files (capped at 10,
    for use with read_file). Matches by id or display name, case-insensitively.
  • Per-bot enablement — new skill_names column on BotProfile (migration 0022), synced
    through the catalog/API like tool_names; a bot must both grant the load_skill tool and
    enable the skill for either to take effect (skills only reach the prompt when the tool is
    granted). The API rejects unknown skill names with 422.

Files changed

Backend: openbot/skills.py (discovery/parsing/section), openbot/tools/builtin/skills.py
(the tool), runtime/runner.py + runtime/prompt.py (prompt wiring), db/models.py +
db/migrations/versions/0022_bot_skill_names.py, api/schemas.py, api/bots.py,
api/tool_validation.py (known_skill_names), api/tools.py (new GET /tools/skills
endpoint for the editor), bots/catalog.py, seed.py (synced fields), config.py
(state_dir property), pyproject.toml (pyyaml).

Frontend: SkillInfo type, Api.listSkills, a Skills card in BotEditorPage (per-bot
toggles + empty-state hint), skill_names on the Bot type.

Also in this PR (task 2): the bot busy indicator now reads "waiting…" instead of
"busy, waiting…" (BotBusyIndicator.tsx label + aria-label, with the three tests that pinned
the old string updated).

Tests

backend/tests/test_skills.py (28 tests): discovery layouts and shadowing, junk-dir pruning,
the 64-skill cap, front-matter validation (malformed YAML, missing fields, bad ids, overlong
fields), the prompt section (ids/descriptions only), per-bot enablement by id and display name,
load_skill happy path/errors/no-disabled-leaks, the sibling-file cap, runner prompt wiring
(skills appear only when load_skill is granted), and API validation (unknown-skill 422,
GET /tools/skills). Plus the editor test that toggles a skill and asserts the saved payload.

Local: backend uv run pytest -q → 815 passed; uv run ruff check . clean. Frontend
pnpm test → 347 passed (57 files), tsc --noEmit clean, pnpm lint exit 0 (one pre-existing
warning in ThreadPage.tsx, untouched).

Port OpenCode's MIT-licensed skill mechanism to Python: discover
skills/**/SKILL.md under the workspace and the OpenBot state dir, put only
names/descriptions in the system prompt, add a per-bot load_skill tool that
returns a skill's body + base dir + sibling files, and per-bot enablement
(bot_profiles.skill_names, migration 0022) validated against discoverable
ids via the API. Frontend: GET /tools/skills-backed skill picker in the bot
editor; GET /api/v1/tools/skills lists discovered skills.

Also: busy indicator text 'busy, waiting...' -> 'waiting...' (label +
aria-label, tests updated).
Making skill_names required on the Bot type broke three other test
factories (botActivity, BotPage, SaveFeedback) that construct a full Bot;
my local tsc check was blinded by an incremental cache, CI caught it.

@regnull regnull left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASS — Ready to merge (COMMENTED verdict because self-approval is blocked on the shared account).

Verified against the diff at 390d46a, all priority targets:

  • client.ts merge resolution ✅ — line 1 import union retains BOTH BotScratchpad and SkillInfo; diffed against origin/main, the only client.ts changes are the import line + listSkills. Scratchpad type/endpoint (from #303) intact.
  • skill_names required on Bot ✅ — every Bot literal on the PR head updated (botActivity.test.ts, BotEditorPage, BotPage, SaveFeedback); grepped all frontend test files that construct Bot (via memory_enabled, also required) — none missed.
  • Malformed front matter non-fatal ✅ — discover_skills swallows SkillError/OSError per file and continues; pinned by 8+ parametrized tests (broken YAML, non-mapping, missing name/description, bad ids, overlong fields — all leave other skills discoverable).
  • Dual gate (grant + enablement) ✅ — runner adds skills to the prompt only when load_skill is granted AND skill_names non-empty (enabled_skills returns [] otherwise); the tool re-filters by the enabled set per call and errors for disabled/unknown skills (pinned by test_load_skill_does_not_offer_disabled_skills). API rejects unknown names with 422 (pinned).
  • Catalog-wide grant risk ✅ — test_selectable_names updated with load_skill; seeded-bot tool-list asserts in test_token_efficiency.py correctly unchanged (demo bots don't get the tool; SYNCED_FIELDS syncs skill_names like the other fields).
  • Report-vs-diff ✅ — both backend cores are full new-file implementations in the diff (170 + 103 lines), no phantom pieces.
  • MIT credit ✅ — present in both new file docstrings.

Migration 0022 (JSON, NOT NULL, server_default '[]'), API surface (create/update/catalog-install), prompt wiring, editor UI and busy-indicator rename (label + aria-label, 3 tests updated) all check out.

⚠️ Non-blocking nits (carry forward, don't hold the merge):

  1. test_workspace_roots_shadow_the_state_dir (backend/tests/test_skills.py) is vacuous: assert skills.id != "Deep Review" passes regardless of which root wins because the id is always "deep". Suggest asserting the winning name (== "Deep Review"). The implementation itself is correct (setdefault, first root wins).
  2. 0022_bot_skill_names.py docstring says "Revision ID: 0021 / Revises: 0020" while the actual constants are 0022/0021.
  3. seed.py dropped to a single blank line between grant_scratchpad_to_existing_bots and sync_demo_bots (cosmetic; CI lint already green).

OpenBot - @reviewer

# Conflicts:
#	backend/openbot/runtime/prompt.py
#	backend/openbot/runtime/runner.py
@regnull
regnull merged commit 5b44bc3 into main Oct 18, 2026
4 checks passed
@regnull
regnull deleted the feat/skills-load-252 branch October 18, 2026 15:01
regnull added a commit that referenced this pull request Oct 18, 2026
- test_skills: replace the vacuous name-vs-id assertion in
  test_workspace_roots_shadow_the_state_dir with a real check that the
  workspace-root copy shadows the state-dir one
- migration 0022: correct the stale revision docstring (0021/0020 -> 0022/0021)
- seed.py: restore two blank lines dropped by the skills commit
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