From 3a252aa0b3504269e4a50e0cfe4c401b7bbaccaa Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 29 Jun 2026 02:13:05 -0700 Subject: [PATCH] [aidan] feat/skills: Skill tool + installed-skills catalog for agent discovery --- .../permissions/build_effective_tool_lists.py | 11 ++ .../prompt/compose_turn_system_prompt.py | 3 + .../agents/manager/prompt/prompt_context.py | 50 +++++-- .../manager/register_builtin_mcp_servers.py | 21 +++ backend/apps/agents/skill_mcp_server.py | 125 ++++++++++++++++++ backend/apps/tools_lib/models.py | 2 + .../src/app/pages/Tools/cards/ToolSection.tsx | 2 + frontend/src/app/pages/Tools/toolsHelpers.ts | 2 +- 8 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 backend/apps/agents/skill_mcp_server.py diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index ad06eb68..eb916bcd 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -60,6 +60,14 @@ def build_effective_tool_lists( effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") continue + if name == "openswarm-skill": + policy = builtin_perms.get("Skill", "always_allow") + if policy == "always_allow": + effective_allowed.append("mcp__openswarm-skill__Skill") + else: + effective_disallowed.append("mcp__openswarm-skill__Skill") + continue + if name == "openswarm-web": # Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. for wt in ("WebSearch", "WebFetch"): @@ -97,4 +105,7 @@ def build_effective_tool_lists( for bt in path_gate.CLAUDE_INTERNAL_SCHEDULER_TOOLS: if bt not in effective_disallowed: effective_disallowed.append(bt) + # The claude_code preset ships its own bare `Skill` tool that reads ~/.claude/skills directly; always withhold it so skills only ever load through our provider-agnostic mcp__openswarm-skill__Skill (or not at all). + if "Skill" not in effective_disallowed: + effective_disallowed.append("Skill") return effective_allowed, effective_disallowed diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 2e311a52..b89f9a40 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -13,6 +13,7 @@ from backend.apps.agents.core.models import AgentSession from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names from backend.apps.agents.manager.prompt.prompt_context import ( build_browser_context, + build_installed_skills_catalog, build_mcp_registry_summary, build_selected_app_context, build_selected_settings_context, @@ -32,12 +33,14 @@ def compose_turn_system_prompt( # MCP servers and their tool inventories are intentionally NOT injected into the system prompt: the CLI's deferred-tool pool already exposes them by name via ToolSearch, and eagerly listing connected MCPs (account emails, full tool enumerations) here would defeat the deferral and leak every integration into every turn. The model discovers MCPs only when it actively calls ToolSearch; only the gated registry summary goes in. browser_ctx = build_browser_context(session.dashboard_id, selected_browser_ids=selected_browser_ids) mcp_registry_ctx = build_mcp_registry_summary(session.allowed_tools, session.active_mcps, get_all_tool_names) + skills_catalog_ctx = build_installed_skills_catalog() composed_prompt = compose_system_prompt( default_system_prompt, mode_sys_prompt, session.system_prompt, browser_ctx, mcp_registry_ctx, + skills_catalog_ctx, ) # Pin the agent's notion of "now" to the host wall clock + zone so it can answer day-of-week questions without hallucinating. diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index 9c3a429d..467c4ec8 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -306,6 +306,42 @@ def build_mcp_registry_summary(allowed_tools: List[str], active_mcps: List[str], return "\n".join(sections) +@typechecked +def build_installed_skills_catalog() -> Optional[str]: + """Compact catalog of the user's installed skills (id + when-to-use), the + surface that lets the model reach for a skill on its own instead of waiting + for a manual `/` attach. Lists only LOCALLY installed, non-built-in skills + (id + description, never the body), so it costs a few hundred tokens, not the + 600k-entry registry. Returns None (no block, no Skill tool) when the Skill + tool is denied or nothing's installed, so the catalog and tool stay in sync.""" + from backend.apps.tools_lib.tools_lib import load_builtin_permissions + if load_builtin_permissions().get("Skill", "always_allow") == "deny": + return None + try: + from backend.apps.skills.skills import sync_skills + skills = [s for s in sync_skills() if not s.built_in] + except Exception: + return None + if not skills: + return None + + # Static preamble kept byte-identical across users so it caches; the per-skill lines below vary per install (same as the mcp registry). + lines = [ + "", + "Skills are reusable playbooks the user installed. Each line is a skill id " + "and when to use it. When a request matches one, call Skill(id=\"\") to " + "load its full instructions, then follow them. Don't load a skill that isn't " + "relevant, and don't guess a skill's contents without loading it.", + "", + "Installed skills:", + ] + for s in skills: + blurb = (s.description or s.name).strip() + lines.append(f"- `{s.id}`, {blurb}") + lines.append("") + return "\n".join(lines) + + # The agent runs on the claude_code preset (kept for its tool scaffolding, safety rules, and the exclude_dynamic_sections prompt-cache win, which a raw-string system prompt would all throw away). The preset opens with "You are Claude Code, Anthropic's official CLI", which leaks into chat. This block is APPENDED after the preset, so being later it overrides that identity. Edit AGENT_NAME / AGENT_BLURB to rebrand. Kept short so it costs ~80 cached tokens, not a wall. AGENT_NAME = "OpenSwarm" AGENT_IDENTITY = ( @@ -326,9 +362,9 @@ AGENT_IDENTITY = ( @typechecked -def compose_system_prompt(default_prompt: Optional[str], mode_prompt: Optional[str], session_prompt: Optional[str], browser_ctx: Optional[str] = None, mcp_registry_ctx: Optional[str] = None) -> Optional[str]: +def compose_system_prompt(default_prompt: Optional[str], mode_prompt: Optional[str], session_prompt: Optional[str], browser_ctx: Optional[str] = None, mcp_registry_ctx: Optional[str] = None, skills_catalog_ctx: Optional[str] = None) -> Optional[str]: # Identity always leads so it overrides the preset's Claude Code persona, even when the user has no custom default/mode/session prompt of their own. - parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, mcp_registry_ctx, browser_ctx) if p] + parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, mcp_registry_ctx, skills_catalog_ctx, browser_ctx) if p] return "\n\n".join(parts) @@ -395,19 +431,13 @@ def resolve_attached_skills(attached_skills: Optional[List]) -> str: except Exception: folder_by_id = {} + from backend.apps.skills.skills import format_skill_for_prompt sections = [] for skill in attached_skills: name = skill.get("name", "Unknown") content = skill.get("content", "") if not content: continue - block = f"[Using skill: {name}]\n\n{content}" folder = folder_by_id.get(skill.get("id", "")) - if folder: - block += ( - f"\n\nThis skill bundles supporting files in {folder}. " - "Read them with your normal file tools (Read / Glob / Bash) when " - "the steps above call for one; don't guess their contents." - ) - sections.append(block) + sections.append(format_skill_for_prompt(name, content, folder)) return "\n\n".join(sections) diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index 62e3b979..e0d4a529 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -89,6 +89,27 @@ def register_builtin_mcp_servers( "type": "stdio", } + # Skill server: exposes the Skill tool so the agent can load an installed skill on its own (the catalog in the prompt lists what's available). Gated on at least one non-built-in skill existing AND Skill not being denied, so we never offer a tool with an empty catalog. Kept in sync with build_installed_skills_catalog, which omits the catalog under the same conditions. + skill_denied = builtin_perms.get("Skill", "always_allow") == "deny" + if not skill_denied: + try: + from backend.apps.skills.skills import sync_skills + has_loadable_skill = any(not s.built_in for s in sync_skills()) + except Exception: + has_loadable_skill = False + if has_loadable_skill: + skill_server_path = os.path.join(agents_dir, "skill_mcp_server.py") + mcp_servers["openswarm-skill"] = { + "command": sys.executable, + "args": [skill_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + # Always-on settings-meta server: SettingsRead / SettingsWrite let the agent read and edit its own OpenSwarm Settings autonomously. The backend (/api/settings-meta) enforces the only two guardrails: it can't disconnect the credential powering this run, and reads come back with secrets redacted. No activation gate, Settings is the agent's own house, not a third-party MCP. settings_meta_server_path = os.path.join( agents_dir, "settings_meta_server.py" diff --git a/backend/apps/agents/skill_mcp_server.py b/backend/apps/agents/skill_mcp_server.py new file mode 100644 index 00000000..2432f5bd --- /dev/null +++ b/backend/apps/agents/skill_mcp_server.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing the Skill tool: loads an installed skill's instructions on demand.""" + +import json +import os +import sys +import urllib.error +import urllib.request + +BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324") +BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "") +LOAD_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/skills/load" + +TOOLS = [ + { + "name": "Skill", + "description": ( + "Load the full instructions for an installed skill by its id (the ids " + "are listed in the block of your system prompt). Returns the " + "skill's SKILL.md body plus a note about any supporting files it bundles. " + "Call this when the user's request matches a listed skill, then follow " + "the loaded instructions." + ), + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The skill id from the catalog (e.g. 'deep-research').", + }, + }, + "required": ["id"], + }, + }, +] + + +def send_response(id_, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": id_} + if error is not None: + msg["error"] = error + else: + msg["result"] = result + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def p_post(url: str, body: dict, timeout: float = 30.0) -> dict: + payload = json.dumps(body).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request( + url, + data=payload, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body_txt = e.read().decode(errors="replace") if e.fp else str(e) + return {"error": f"HTTP {e.code}: {body_txt[:500]}"} + except Exception as e: + return {"error": str(e)} + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name == "Skill": + skill_id = str(arguments.get("id", "")).strip() + if not skill_id: + return {"content": [{"type": "text", "text": "Error: id is required"}], "isError": True} + r = p_post(LOAD_URL, {"id": skill_id}) + if "error" in r: + return {"content": [{"type": "text", "text": f"Failed to load skill: {r['error']}"}], "isError": True} + if not r.get("ok"): + available = r.get("available", []) + hint = ", ".join(available) if available else "none installed" + return {"content": [{"type": "text", "text": f"No skill with id {skill_id!r}. Installed skill ids: {hint}."}], "isError": True} + return {"content": [{"type": "text", "text": r.get("text", "")}]} + + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + + method = msg.get("method") + id_ = msg.get("id") + params = msg.get("params", {}) or {} + + if method == "initialize": + send_response(id_, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "openswarm-skill", + "version": "1.0.0", + }, + }) + elif method == "notifications/initialized": + pass + elif method == "tools/list": + send_response(id_, {"tools": TOOLS}) + elif method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) or {} + result = handle_tool_call(tool_name, arguments) + send_response(id_, result) + elif method == "ping": + send_response(id_, {}) + elif id_ is not None: + send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"}) + + +if __name__ == "__main__": + main() diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py index 56f795c9..71d81261 100644 --- a/backend/apps/tools_lib/models.py +++ b/backend/apps/tools_lib/models.py @@ -33,6 +33,8 @@ BUILTIN_TOOLS: list[BuiltinTool] = [ BuiltinTool(name="CronCreate", description="Create a scheduled or recurring task", category="scheduling", deferred=True), BuiltinTool(name="CronList", description="List all scheduled tasks", category="scheduling", deferred=True), BuiltinTool(name="CronDelete", description="Delete a scheduled task", category="scheduling", deferred=True), + # Skills + BuiltinTool(name="Skill", description="Load an installed skill's instructions on demand so the agent can find and use the right skill itself", category="skills"), # Agent tools BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"), BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"), diff --git a/frontend/src/app/pages/Tools/cards/ToolSection.tsx b/frontend/src/app/pages/Tools/cards/ToolSection.tsx index 03f42fc5..9c32964e 100644 --- a/frontend/src/app/pages/Tools/cards/ToolSection.tsx +++ b/frontend/src/app/pages/Tools/cards/ToolSection.tsx @@ -20,6 +20,7 @@ import BlockIcon from '@mui/icons-material/Block'; import SecurityIcon from '@mui/icons-material/Security'; import PanToolIcon from '@mui/icons-material/PanTool'; import CallSplitIcon from '@mui/icons-material/CallSplit'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import { BuiltinTool } from '@/shared/state/toolsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { CATEGORY_ORDER } from '../toolsHelpers'; @@ -60,6 +61,7 @@ const ToolSection: React.FC = ({ planning: { label: 'Planning', color: '#ec4899', icon: }, scheduling: { label: 'Scheduling', color: '#14b8a6', icon: }, agents: { label: 'Agents', color: '#f97316', icon: }, + skills: { label: 'Skills', color: '#7B61BD', icon: }, }; const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => ( diff --git a/frontend/src/app/pages/Tools/toolsHelpers.ts b/frontend/src/app/pages/Tools/toolsHelpers.ts index bd0af9b5..5ffe9fb2 100644 --- a/frontend/src/app/pages/Tools/toolsHelpers.ts +++ b/frontend/src/app/pages/Tools/toolsHelpers.ts @@ -1,6 +1,6 @@ import { McpServer } from '@/shared/state/mcpRegistrySlice'; -export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling']; +export const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'skills', 'planning', 'scheduling']; export interface ToolForm { name: string;