mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-01 04:38:52 +02:00
[aidan] feat/skills: Skill tool + installed-skills catalog for agent discovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>",
|
||||
"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=\"<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("</skills>")
|
||||
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)
|
||||
|
||||
@@ -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 <skills> 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"
|
||||
|
||||
@@ -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 <skills> 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 <skills> 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()
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<ToolSectionProps> = ({
|
||||
planning: { label: 'Planning', color: '#ec4899', icon: <MapIcon sx={{ fontSize: 16 }} /> },
|
||||
scheduling: { label: 'Scheduling', color: '#14b8a6', icon: <ScheduleIcon sx={{ fontSize: 16 }} /> },
|
||||
agents: { label: 'Agents', color: '#f97316', icon: <CallSplitIcon sx={{ fontSize: 16 }} /> },
|
||||
skills: { label: 'Skills', color: '#7B61BD', icon: <AutoAwesomeIcon sx={{ fontSize: 16 }} /> },
|
||||
};
|
||||
|
||||
const PermToggle = ({ value, onChange, size = 16 }: { value: string; onChange: (v: string) => void; size?: number }) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user