Skip to content

Commit 5b44bc3

Browse files
authored
feat: skills — SKILL.md files loaded on demand (#252) (#302)
* WIP: skills (#252) from engineer run 01ec3c9f; migration renumbered to 0022 * feat: skills — SKILL.md files loaded on demand (#252) 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). * fix: add skill_names to the remaining Bot test fixtures 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.
1 parent 668bfcf commit 5b44bc3

28 files changed

Lines changed: 769 additions & 22 deletions

‎backend/openbot/api/bots.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
ScratchpadOut,
2222
bot_out,
2323
)
24-
from openbot.api.tool_validation import known_tool
24+
from openbot.api.tool_validation import known_skill_names, known_tool
2525
from openbot.db.models import (
2626
ACTIVE_RUN_STATUSES,
2727
OPEN_RUN_STATUSES,
@@ -50,14 +50,20 @@ async def _usage_for(session: AsyncSession, actor_id: str) -> dict[str, int | fl
5050
return dict(zip(USAGE_FIELDS, row, strict=True))
5151

5252

53-
def _validate_tools(services: Services, tool_names: list[str], approval_tools: list[str]) -> None:
53+
def _validate_tools(services: Services, tool_names: list[str], approval_tools: list[str],
54+
skill_names: list[str] | None = None) -> None:
5455
if tool_names:
5556
unknown = [t for t in tool_names if not known_tool(services, t)]
5657
if unknown:
5758
raise HTTPException(422, f"unknown tools: {unknown}")
5859
extra = [t for t in approval_tools if t not in tool_names]
5960
if extra:
6061
raise HTTPException(422, f"approval_tools must be a subset of tool_names: {extra}")
62+
if skill_names:
63+
known = known_skill_names(services)
64+
unknown = [s for s in skill_names if s.lower() not in known]
65+
if unknown:
66+
raise HTTPException(422, f"unknown skills: {unknown}")
6167

6268

6369
def _validate_model(provider: str, model: str, model_settings: dict | None = None) -> None:
@@ -92,7 +98,7 @@ async def list_bots(session: AsyncSession = Depends(get_session)):
9298
@router.post("", response_model=BotOut, status_code=201)
9399
async def create_bot(body: BotCreate, session: AsyncSession = Depends(get_session),
94100
services: Services = Depends(get_services)):
95-
_validate_tools(services, body.tool_names, body.approval_tools)
101+
_validate_tools(services, body.tool_names, body.approval_tools, body.skill_names)
96102
_validate_model(body.provider, body.model, body.model_settings)
97103
if await handle_taken(session, body.handle):
98104
raise HTTPException(409, "handle already exists")
@@ -136,8 +142,9 @@ async def install_catalog_entry(entry_id: str, body: BotCatalogInstall | None =
136142
data = {"handle": handle, "name": item.name, "description": item.description, "icon": item.icon,
137143
"instructions": instructions, "provider": "auto", "model": "", "model_settings": item.model_settings,
138144
"tool_names": list(item.tool_names), "approval_tools": list(item.approval_tools),
145+
"skill_names": list(item.skill_names),
139146
"memory_enabled": True, "load_repository_instructions": item.load_repository_instructions, "enabled": True}
140-
_validate_tools(services, data["tool_names"], data["approval_tools"])
147+
_validate_tools(services, data["tool_names"], data["approval_tools"], data["skill_names"])
141148
actor = Actor(kind="bot", **{k: data.pop(k) for k in ACTOR_FIELDS}, bot=BotProfile(**data))
142149
session.add(actor)
143150
try:
@@ -162,7 +169,8 @@ async def update_bot(bot_id: str, body: BotUpdate, session: AsyncSession = Depen
162169
services: Services = Depends(get_services)):
163170
actor = await _get_bot_or_404(session, bot_id)
164171
data = body.model_dump(exclude_unset=True)
165-
_validate_tools(services, data.get("tool_names", actor.bot.tool_names), data.get("approval_tools", actor.bot.approval_tools))
172+
_validate_tools(services, data.get("tool_names", actor.bot.tool_names), data.get("approval_tools", actor.bot.approval_tools),
173+
data.get("skill_names", actor.bot.skill_names))
166174
_validate_model(data.get("provider", actor.bot.provider), data.get("model", actor.bot.model),
167175
data.get("model_settings", actor.bot.model_settings))
168176
if "handle" in data and data["handle"] != actor.handle and await handle_taken(session, data["handle"]):

‎backend/openbot/api/schemas.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class BotCreate(BaseModel):
7070
tool_names: list[str] = []
7171
approval_tools: list[str] = []
7272
memory_enabled: bool = True
73+
skill_names: list[str] = []
7374
load_repository_instructions: bool = False
7475
enabled: bool = True
7576

@@ -103,6 +104,7 @@ class BotUpdate(BaseModel):
103104
tool_names: list[str] | None = None
104105
approval_tools: list[str] | None = None
105106
memory_enabled: bool | None = None
107+
skill_names: list[str] | None = None
106108
load_repository_instructions: bool | None = None
107109
enabled: bool | None = None
108110

@@ -132,6 +134,7 @@ class BotOut(BaseModel):
132134
tool_names: list[str]
133135
approval_tools: list[str]
134136
memory_enabled: bool
137+
skill_names: list[str]
135138
load_repository_instructions: bool
136139
model_calls: int = 0
137140
prompt_tokens: int = 0
@@ -430,6 +433,7 @@ def bot_out(actor, *, active: bool = False, usage: dict[str, int | float] | None
430433
icon=p.icon or DEFAULT_BOT_ICON, enabled=actor.enabled, active=active,
431434
instructions=p.instructions, provider=p.provider, model=p.model, model_settings=p.model_settings,
432435
tool_names=p.tool_names, approval_tools=p.approval_tools, memory_enabled=p.memory_enabled,
436+
skill_names=p.skill_names,
433437
load_repository_instructions=p.load_repository_instructions,
434438
model_calls=int(usage.get("model_calls", 0)), prompt_tokens=int(usage.get("prompt_tokens", 0)),
435439
completion_tokens=int(usage.get("completion_tokens", 0)), cache_read_tokens=int(usage.get("cache_read_tokens", 0)),
@@ -519,6 +523,7 @@ class BotCatalogEntryOut(BaseModel):
519523
tool_names: list[str]
520524
approval_tools: list[str]
521525
model_settings: dict[str, Any]
526+
skill_names: list[str] = []
522527
load_repository_instructions: bool = False
523528

524529

‎backend/openbot/api/tool_validation.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from openbot.services import Services
4+
from openbot.skills import discover_skills
45

56

67
def known_tool(services: Services, name: str) -> bool:
@@ -10,3 +11,22 @@ def known_tool(services: Services, name: str) -> bool:
1011
if services.mcp is not None and "__" in name:
1112
return services.mcp.has(name.split("__", 1)[0])
1213
return False
14+
15+
16+
def known_skill_names(services: Services) -> set[str]:
17+
"""Skill ids and names discoverable right now (workspace + state dir), case-folded.
18+
19+
Tolerates a None settings registry the same way known_tool does, so tests and early
20+
boot can call it.
21+
"""
22+
settings = getattr(services, "settings", None)
23+
if settings is None:
24+
return set()
25+
roots = [settings.workspace_root]
26+
state_dir = getattr(settings, "state_dir", None)
27+
if state_dir is not None:
28+
roots.append(state_dir)
29+
skills = discover_skills(*[root for root in roots if root is not None])
30+
folded = {s.id.lower() for s in skills}
31+
folded.update(s.name.lower() for s in skills)
32+
return folded

‎backend/openbot/api/tools.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from openbot.api.deps import get_services
44
from openbot.services import Services
5+
from openbot.skills import discover_skills
56

67
router = APIRouter(prefix="/tools", tags=["tools"])
78

@@ -26,3 +27,19 @@ async def list_tools(services: Services = Depends(get_services)):
2627
],
2728
"errors": reg.load_errors,
2829
}
30+
31+
32+
@router.get("/skills")
33+
async def list_skills(services: Services = Depends(get_services)):
34+
"""Discovered skills for the bot editor's per-bot skill picker."""
35+
settings = services.settings
36+
roots = [settings.workspace_root]
37+
state_dir = settings.state_dir
38+
if state_dir is not None and state_dir.resolve() != settings.workspace_root.resolve():
39+
roots.append(state_dir)
40+
return {
41+
"skills": [
42+
{"id": s.id, "name": s.name, "description": s.description, "path": s.path}
43+
for s in discover_skills(*roots)
44+
]
45+
}

‎backend/openbot/bots/catalog.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class BotCatalogEntry(BaseModel):
2121
tool_names: tuple[str, ...] = ()
2222
approval_tools: tuple[str, ...] = ()
2323
model_settings: dict = {}
24+
skill_names: tuple[str, ...] = ()
2425
load_repository_instructions: bool = False
2526

2627

‎backend/openbot/config.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ def _apply_root_directory_defaults(self):
147147
self.secret_key_file = root / ".openbot" / "secret.key"
148148
return self
149149

150+
@property
151+
def state_dir(self) -> Path | None:
152+
"""OpenBot's own state directory: skills shipped with the app live in `<root>/.openbot/skills`,
153+
where workspace tools refuse to go but skill loading deliberately reads."""
154+
if self.root_directory is None:
155+
return None
156+
return Path(self.root_directory) / ".openbot"
157+
150158
@model_validator(mode="after")
151159
def _default_log_file(self):
152160
"""Without an explicit LOG_FILE, log beside the database (after the root-directory defaults, which
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""bot skill_names: which skills a bot may load on demand
2+
3+
Revision ID: 0021
4+
Revises: 0020
5+
"""
6+
7+
import sqlalchemy as sa
8+
from alembic import op
9+
10+
revision = "0022"
11+
down_revision = "0021"
12+
branch_labels = depends_on = None
13+
14+
15+
def upgrade():
16+
op.add_column("bot_profiles", sa.Column("skill_names", sa.JSON(), nullable=False, server_default=sa.text("'[]'")))
17+
18+
19+
def downgrade():
20+
op.drop_column("bot_profiles", "skill_names")

‎backend/openbot/db/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class BotProfile(Base):
101101
tool_names: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
102102
approval_tools: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
103103
memory_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
104+
skill_names: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
104105
load_repository_instructions: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
105106
actor: Mapped[Actor] = relationship(back_populates="bot")
106107

‎backend/openbot/runtime/prompt.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from openbot.db.models import Actor, Message
99
from openbot.runtime.tool_loading import deferred_tools_section
10+
from openbot.skills import SkillInfo, skills_section
1011
from openbot.tools.builtin.scratchpad import scratchpad_section
1112
from openbot.tools.builtin.todo import todo_list_section
1213

@@ -167,6 +168,7 @@ def build_system_prompt(
167168
scoped: bool = False,
168169
todos: list[dict] | None = None,
169170
scratchpad: str | None = None,
171+
skills: list[SkillInfo] | None = None,
170172
deferred_tools: list[str] | None = None,
171173
) -> str:
172174
stable, dynamic = build_system_prompt_parts(
@@ -181,6 +183,7 @@ def build_system_prompt(
181183
scoped=scoped,
182184
todos=todos,
183185
scratchpad=scratchpad,
186+
skills=skills,
184187
deferred_tools=deferred_tools,
185188
)
186189
return stable + dynamic
@@ -199,6 +202,7 @@ def build_system_prompt_parts(
199202
scoped: bool = False,
200203
todos: list[dict] | None = None,
201204
scratchpad: str | None = None,
205+
skills: list[SkillInfo] | None = None,
202206
deferred_tools: list[str] | None = None,
203207
) -> tuple[str, str]:
204208
roster = (
@@ -258,6 +262,8 @@ def build_system_prompt_parts(
258262
dynamic += f"\n\n{scratchpad_section(scratchpad)}"
259263
if todos:
260264
dynamic += f"\n\n{todo_list_section(todos)}"
265+
if skills:
266+
dynamic += f"\n\n{skills_section(skills)}"
261267
repository_instructions = load_repository_instructions(
262268
workspace_root, enabled=bot.bot.load_repository_instructions, start_directory=workspace_root
263269
)

‎backend/openbot/runtime/runner.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
UpstreamPins,
6363
upstream_of,
6464
)
65+
from openbot.skills import enabled_skills
6566
from openbot.tools.builtin.core import CORE_TOOLS
6667
from openbot.tools.builtin.scheduling import SCHEDULING_TOOLS
6768
from openbot.tools.builtin.scratchpad import load_scratchpad
@@ -683,6 +684,13 @@ async def _prepare(self, bot: Actor, thread: Thread, run: Run) -> tuple[str, dic
683684
else []
684685
)
685686
workspace_root = thread_workspace_root(st.workspace_root, thread.working_directory)
687+
# Skills the bot has enabled: only ids/names/descriptions go into the prompt;
688+
# the load_skill tool fetches the body on demand.
689+
skills = (
690+
enabled_skills(bot.bot, workspace_root, self.s.settings)
691+
if "load_skill" in bot.bot.tool_names
692+
else []
693+
)
686694
deferred = set(self.deferred_tool_names(bot))
687695
stable_prompt, dynamic_prompt = build_system_prompt_parts(
688696
bot=bot,
@@ -702,6 +710,7 @@ async def _prepare(self, bot: Actor, thread: Thread, run: Run) -> tuple[str, dic
702710
scoped=scoped,
703711
todos=todos,
704712
scratchpad=scratchpad,
713+
skills=skills,
705714
deferred_tools=[n for n in bot.bot.tool_names if n in deferred],
706715
)
707716
prompt = SystemMessage(

0 commit comments

Comments
 (0)