feat(skills): add phase-1 skill registry and toggles (v1.7.4)
This commit is contained in:
194
app.py
194
app.py
@@ -56,7 +56,7 @@ syslog_handler.setFormatter(
|
|||||||
log.addHandler(syslog_handler)
|
log.addHandler(syslog_handler)
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
VERSION = "1.7.3"
|
VERSION = "1.7.4"
|
||||||
OLLAMA_BASE = "http://localhost:11434"
|
OLLAMA_BASE = "http://localhost:11434"
|
||||||
SEARXNG_BASE = "http://localhost:8888"
|
SEARXNG_BASE = "http://localhost:8888"
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
@@ -103,13 +103,91 @@ MAX_PRESET_PROMPT_CHARS = 12000
|
|||||||
MAX_SETTINGS_KEYS = 16
|
MAX_SETTINGS_KEYS = 16
|
||||||
MAX_SETTINGS_VALUE_CHARS = 8000
|
MAX_SETTINGS_VALUE_CHARS = 8000
|
||||||
MAX_CONVERSATION_TITLE_CHARS = 200
|
MAX_CONVERSATION_TITLE_CHARS = 200
|
||||||
|
MAX_SKILL_KEY_CHARS = 120
|
||||||
|
MAX_SKILL_PROMPT_CHARS = 1600
|
||||||
ALLOWED_SETTINGS_KEYS = {
|
ALLOWED_SETTINGS_KEYS = {
|
||||||
"profile_enabled",
|
"profile_enabled",
|
||||||
"default_model",
|
"default_model",
|
||||||
"search_enabled",
|
"search_enabled",
|
||||||
"memory_enabled",
|
"memory_enabled",
|
||||||
|
"skills_enabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BUILTIN_SKILLS = [
|
||||||
|
{
|
||||||
|
"key": "memory.search",
|
||||||
|
"name": "Memory Search",
|
||||||
|
"category": "memory",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Search stored memory facts relevant to the current prompt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "memory.add",
|
||||||
|
"name": "Memory Add",
|
||||||
|
"category": "memory",
|
||||||
|
"risk": "medium",
|
||||||
|
"description": "Store a new memory fact with topic tagging.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "memory.forget",
|
||||||
|
"name": "Memory Forget",
|
||||||
|
"category": "memory",
|
||||||
|
"risk": "high",
|
||||||
|
"description": "Delete matching memories when asked to forget information.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversation.list",
|
||||||
|
"name": "Conversation List",
|
||||||
|
"category": "conversation",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "List existing conversations with metadata.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversation.get",
|
||||||
|
"name": "Conversation Get",
|
||||||
|
"category": "conversation",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Read a conversation and its message history.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversation.delete",
|
||||||
|
"name": "Conversation Delete",
|
||||||
|
"category": "conversation",
|
||||||
|
"risk": "high",
|
||||||
|
"description": "Delete a single conversation thread.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversation.delete_all",
|
||||||
|
"name": "Conversation Delete All",
|
||||||
|
"category": "conversation",
|
||||||
|
"risk": "high",
|
||||||
|
"description": "Delete all conversations and messages.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "search.web",
|
||||||
|
"name": "Web Search",
|
||||||
|
"category": "search",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Run explicit web search and summarize results.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "settings.get",
|
||||||
|
"name": "Settings Get",
|
||||||
|
"category": "settings",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Read current runtime settings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "settings.update",
|
||||||
|
"name": "Settings Update",
|
||||||
|
"category": "settings",
|
||||||
|
"risk": "high",
|
||||||
|
"description": "Update allowlisted runtime settings keys.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SKILLS_BY_KEY = {s["key"]: s for s in BUILTIN_SKILLS}
|
||||||
|
|
||||||
# --- Templates and Static Files ---
|
# --- Templates and Static Files ---
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
|
|
||||||
@@ -424,6 +502,13 @@ def init_db():
|
|||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
|
skill_key TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# FTS5 Memory table
|
# FTS5 Memory table
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
@@ -462,6 +547,7 @@ def init_db():
|
|||||||
"default_model": DEFAULT_MODEL,
|
"default_model": DEFAULT_MODEL,
|
||||||
"search_enabled": "true",
|
"search_enabled": "true",
|
||||||
"memory_enabled": "true",
|
"memory_enabled": "true",
|
||||||
|
"skills_enabled": "true",
|
||||||
}
|
}
|
||||||
for key, value in defaults.items():
|
for key, value in defaults.items():
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
@@ -472,6 +558,18 @@ def init_db():
|
|||||||
"INSERT INTO settings (key, value) VALUES (?, ?)", (key, value)
|
"INSERT INTO settings (key, value) VALUES (?, ?)", (key, value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Seed skill toggle records for built-in skills.
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for skill in BUILTIN_SKILLS:
|
||||||
|
existing_skill = conn.execute(
|
||||||
|
"SELECT skill_key FROM skills WHERE skill_key = ?", (skill["key"],)
|
||||||
|
).fetchone()
|
||||||
|
if not existing_skill:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO skills (skill_key, enabled, updated_at) VALUES (?, 1, ?)",
|
||||||
|
(skill["key"], now),
|
||||||
|
)
|
||||||
|
|
||||||
# Seed admin PIN hash if missing.
|
# Seed admin PIN hash if missing.
|
||||||
existing_pin_hash = conn.execute(
|
existing_pin_hash = conn.execute(
|
||||||
"SELECT value FROM settings WHERE key = 'admin_pin_hash'"
|
"SELECT value FROM settings WHERE key = 'admin_pin_hash'"
|
||||||
@@ -524,6 +622,55 @@ def get_setting(db, key: str, default: str = "") -> str:
|
|||||||
return row["value"] if row else default
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
|
def list_skills_with_state(db) -> list[dict]:
|
||||||
|
"""Merge built-in skill registry with persisted enable/disable state."""
|
||||||
|
rows = db.execute("SELECT skill_key, enabled, updated_at FROM skills").fetchall()
|
||||||
|
state_by_key = {
|
||||||
|
row["skill_key"]: {
|
||||||
|
"enabled": bool(row["enabled"]),
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
for skill in BUILTIN_SKILLS:
|
||||||
|
state = state_by_key.get(skill["key"], {"enabled": True, "updated_at": ""})
|
||||||
|
merged.append(
|
||||||
|
{
|
||||||
|
**skill,
|
||||||
|
"enabled": state["enabled"],
|
||||||
|
"updated_at": state["updated_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(merged, key=lambda s: (s["category"], s["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
def set_skill_enabled(db, skill_key: str, enabled: bool) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO skills (skill_key, enabled, updated_at) VALUES (?, ?, ?)",
|
||||||
|
(skill_key, 1 if enabled else 0, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_active_skills_prompt(skills: list[dict]) -> str:
|
||||||
|
"""Build a bounded active-skill prompt block for tool-awareness."""
|
||||||
|
lines = [
|
||||||
|
"## Active Skills",
|
||||||
|
"Use these skills only when needed. Prefer concise answers over unnecessary tool usage.",
|
||||||
|
]
|
||||||
|
for skill in skills:
|
||||||
|
lines.append(
|
||||||
|
f"- {skill['key']} ({skill['risk']} risk): {skill['description']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = "\n".join(lines)
|
||||||
|
if len(text) > MAX_SKILL_PROMPT_CHARS:
|
||||||
|
return text[: MAX_SKILL_PROMPT_CHARS - 3] + "..."
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MEMORY SYSTEM (FTS5)
|
# MEMORY SYSTEM (FTS5)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1533,6 +1680,46 @@ async def update_settings(request: Request):
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Skills ---
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/skills")
|
||||||
|
async def list_skills():
|
||||||
|
db = get_db()
|
||||||
|
skills = list_skills_with_state(db)
|
||||||
|
db.close()
|
||||||
|
return {"skills": skills, "count": len(skills)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/skills/active")
|
||||||
|
async def list_active_skills():
|
||||||
|
db = get_db()
|
||||||
|
skills_enabled = get_setting(db, "skills_enabled", "true") == "true"
|
||||||
|
skills = list_skills_with_state(db)
|
||||||
|
db.close()
|
||||||
|
active = [s for s in skills if s["enabled"]] if skills_enabled else []
|
||||||
|
return {"skills": active, "count": len(active), "skills_enabled": skills_enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/skills/{skill_key}")
|
||||||
|
async def update_skill(skill_key: str, request: Request):
|
||||||
|
skill_key = skill_key.strip()
|
||||||
|
if len(skill_key) > MAX_SKILL_KEY_CHARS or skill_key not in SKILLS_BY_KEY:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown skill")
|
||||||
|
|
||||||
|
body = await read_json_body(request, BODY_LIMIT_DEFAULT_BYTES)
|
||||||
|
if "enabled" not in body or not isinstance(body.get("enabled"), bool):
|
||||||
|
raise HTTPException(status_code=400, detail="Field 'enabled' (boolean) is required")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
set_skill_enabled(db, skill_key, bool(body["enabled"]))
|
||||||
|
db.commit()
|
||||||
|
skills = list_skills_with_state(db)
|
||||||
|
db.close()
|
||||||
|
updated = next((s for s in skills if s["key"] == skill_key), None)
|
||||||
|
return {"status": "ok", "skill": updated}
|
||||||
|
|
||||||
|
|
||||||
# --- System Presets ---
|
# --- System Presets ---
|
||||||
|
|
||||||
|
|
||||||
@@ -1843,6 +2030,11 @@ def build_system_prompt(db, extra_prompt="", user_message=""):
|
|||||||
parts.append("## Relevant Context from Memory\n" + "\n".join(memory_lines))
|
parts.append("## Relevant Context from Memory\n" + "\n".join(memory_lines))
|
||||||
log.debug(f"Injected {len(memories)} memories into context")
|
log.debug(f"Injected {len(memories)} memories into context")
|
||||||
|
|
||||||
|
if settings.get("skills_enabled", "true") == "true":
|
||||||
|
active_skills = [s for s in list_skills_with_state(db) if s["enabled"]]
|
||||||
|
if active_skills:
|
||||||
|
parts.append(format_active_skills_prompt(active_skills))
|
||||||
|
|
||||||
if extra_prompt and extra_prompt.strip():
|
if extra_prompt and extra_prompt.strip():
|
||||||
parts.append(extra_prompt.strip())
|
parts.append(extra_prompt.strip())
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Total identified items: 26
|
|||||||
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).
|
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).
|
||||||
7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes.
|
7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes.
|
||||||
8. [P1][DONE] Add automated tests for chat streaming, auto-search trigger, and memory command paths.
|
8. [P1][DONE] Add automated tests for chat streaming, auto-search trigger, and memory command paths.
|
||||||
9. [P2] Implement skills/tool-call framework (MCP-style) with per-skill enable controls.
|
9. [P2][DONE] Implement skills/tool-call framework (MCP-style) with per-skill enable controls.
|
||||||
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
|
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
|
||||||
|
|
||||||
## Item 1 Executive Summary (Scope + Security)
|
## Item 1 Executive Summary (Scope + Security)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ⚡ JarvisChat v1.7.3
|
# ⚡ JarvisChat v1.7.4
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ Top 10 (brief):
|
|||||||
6. P1: Add pagination + hard caps for list APIs
|
6. P1: Add pagination + hard caps for list APIs
|
||||||
7. P1 [DONE]: Replace raw exception leakage with safe client errors
|
7. P1 [DONE]: Replace raw exception leakage with safe client errors
|
||||||
8. P1 [DONE]: Add automated tests for streaming/search/memory paths
|
8. P1 [DONE]: Add automated tests for streaming/search/memory paths
|
||||||
9. P2: Implement MCP-style skills/tool-call framework
|
9. P2 [DONE]: Implement MCP-style skills/tool-call framework
|
||||||
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
|
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
|
||||||
|
|
||||||
Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default.
|
Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default.
|
||||||
|
|||||||
93
tests/test_skills_framework.py
Normal file
93
tests/test_skills_framework.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(tmp_path: Path) -> TestClient:
|
||||||
|
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||||
|
app_module.DB_PATH = tmp_path / "jarvischat-skills.db"
|
||||||
|
app_module.SESSIONS.clear()
|
||||||
|
app_module.PIN_ATTEMPTS.clear()
|
||||||
|
app_module.RATE_EVENTS.clear()
|
||||||
|
app_module.init_db()
|
||||||
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_can_list_skills(tmp_path: Path):
|
||||||
|
with make_client(tmp_path) as client:
|
||||||
|
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||||
|
"session_id"
|
||||||
|
]
|
||||||
|
resp = client.get("/api/skills", headers={"X-Session-ID": sid})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.json()
|
||||||
|
assert payload["count"] >= 1
|
||||||
|
assert any(skill["key"] == "memory.search" for skill in payload["skills"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_toggle_skill_enabled_state(tmp_path: Path):
|
||||||
|
with make_client(tmp_path) as client:
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"pin": "1234"},
|
||||||
|
headers={"Origin": "http://testserver"},
|
||||||
|
)
|
||||||
|
sid = login.json()["session_id"]
|
||||||
|
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||||
|
|
||||||
|
disable = client.put(
|
||||||
|
"/api/skills/search.web",
|
||||||
|
json={"enabled": False},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert disable.status_code == 200
|
||||||
|
assert disable.json()["skill"]["enabled"] is False
|
||||||
|
|
||||||
|
active = client.get("/api/skills/active", headers={"X-Session-ID": sid})
|
||||||
|
assert active.status_code == 200
|
||||||
|
assert all(skill["key"] != "search.web" for skill in active.json()["skills"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_skill_update_is_rejected(tmp_path: Path):
|
||||||
|
with make_client(tmp_path) as client:
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"pin": "1234"},
|
||||||
|
headers={"Origin": "http://testserver"},
|
||||||
|
)
|
||||||
|
sid = login.json()["session_id"]
|
||||||
|
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
"/api/skills/nope.unknown",
|
||||||
|
json={"enabled": True},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_injection_respects_skills_enabled_setting(tmp_path: Path):
|
||||||
|
with make_client(tmp_path):
|
||||||
|
db = app_module.get_db()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
("skills_enabled", "false"),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
without_skills = app_module.build_system_prompt(db, "", "hello")
|
||||||
|
assert "## Active Skills" not in without_skills
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
("skills_enabled", "true"),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with_skills = app_module.build_system_prompt(db, "", "hello")
|
||||||
|
assert "## Active Skills" in with_skills
|
||||||
|
assert "memory.search" in with_skills
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Reference in New Issue
Block a user