Skip to content

Commit a8c4a21

Browse files
authored
Merge pull request #52 from mattmezza/feat/skills-on-demand
feat(skills): serve the index on-demand via search_skills/list_skills (#50)
2 parents 4257fb1 + 23e09d1 commit a8c4a21

12 files changed

Lines changed: 443 additions & 46 deletions

File tree

api/admin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1848,6 +1848,7 @@ async def system_prompt_preview(body: PromptPreviewIn) -> dict:
18481848
secrets_available=secret_store is not None,
18491849
include_memories=body.include_memories,
18501850
include_reflections=body.include_reflections,
1851+
skills_on_demand=config.agent.skills_index_mode == "on_demand",
18511852
)
18521853
full_prompt = sections.full_prompt
18531854
section_map = sections.as_dict()
@@ -3412,7 +3413,8 @@ def _config_requires_restart(values: dict) -> bool:
34123413

34133414
# Function-tools that a persona may scope. ``load_skill`` is intentionally
34143415
# excluded — it is always available (the core mechanic personae use to read
3415-
# their allowlisted skills); so are the vault tools and ``recall_memory``
3416+
# their allowlisted skills); so are ``search_skills``/``list_skills`` (its
3417+
# on-demand discovery counterparts — #50), the vault tools, and ``recall_memory``
34163418
# (memory is injected for every persona, scope-filtered, so its on-demand
34173419
# counterpart is always available too). Kept here (not imported from core.agent)
34183420
# to avoid pulling the agent's heavy import graph into the admin app.

core/agent.py

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from core.models import AgentResponse, Attachment
2929
from core.permissions import PermissionEngine, PermissionLevel, format_approval_message
3030
from core.personae import Persona, PersonaStore
31-
from core.prompt_builder import build_prompt_sections
31+
from core.prompt_builder import SKILLS_DISCOVERY_POINTER, build_prompt_sections
3232
from core.scheduler import AgentScheduler
3333
from core.secret_store import SecretStore
3434
from core.skills import SkillsEngine
@@ -198,6 +198,41 @@ def _shell_quote(s: str) -> str:
198198
"required": ["name"],
199199
},
200200
},
201+
# Skill discovery (#50) — only advertised when skills_index_mode == "on_demand"
202+
# (the full index is NOT injected then). Return name + summary, never bodies;
203+
# the model then calls load_skill to read the chosen skill in full.
204+
{
205+
"name": "search_skills",
206+
"description": (
207+
"Find skills relevant to the current task. Returns the top matching skills "
208+
"as name + summary (NOT their full content). Pass a short natural-language "
209+
"query or keywords describing what you need to do, then call `load_skill` "
210+
"with a returned name to read that skill's full instructions."
211+
),
212+
"input_schema": {
213+
"type": "object",
214+
"properties": {
215+
"query": {
216+
"type": "string",
217+
"description": "What you want to do (keywords or a short phrase)",
218+
},
219+
"limit": {
220+
"type": "integer",
221+
"description": "Max skills to return (default 10).",
222+
},
223+
},
224+
"required": ["query"],
225+
},
226+
},
227+
{
228+
"name": "list_skills",
229+
"description": (
230+
"List every skill available to you as name + summary (NOT full content). "
231+
"Use this to browse the whole catalogue; prefer `search_skills` when you "
232+
"know what you're after. Call `load_skill` with a name to read one in full."
233+
),
234+
"input_schema": {"type": "object", "properties": {}},
235+
},
201236
{
202237
"name": "recall_memory",
203238
"description": (
@@ -493,9 +528,19 @@ def scoped_tools(persona: Persona | None) -> list[dict]:
493528
return TOOLS
494529
# ``load_skill`` and the vault discovery/request tools are always retained:
495530
# they are the mechanics personae rely on to read skills and obtain secrets.
496-
# ``recall_memory`` too — memory is injected for every persona (scope-filtered),
497-
# so its on-demand counterpart exposes nothing extra and stays available (#47).
498-
_always = {"load_skill", "recall_memory", "list_secrets", "request_secret"}
531+
# ``search_skills``/``list_skills`` mirror ``load_skill`` (a persona needs them
532+
# to discover its own allowlisted skills in on-demand mode — #50); the feature
533+
# gate below still drops them when that mode is off. ``recall_memory`` too —
534+
# memory is injected for every persona (scope-filtered), so its on-demand
535+
# counterpart exposes nothing extra and stays available (#47).
536+
_always = {
537+
"load_skill",
538+
"search_skills",
539+
"list_skills",
540+
"recall_memory",
541+
"list_secrets",
542+
"request_secret",
543+
}
499544
return [t for t in TOOLS if persona.allows_tool(t["name"]) or t["name"] in _always]
500545

501546

@@ -504,16 +549,21 @@ def apply_feature_gates(
504549
*,
505550
secrets_available: bool,
506551
artifacts_enabled: bool,
552+
skills_on_demand: bool = False,
507553
subagents_enabled: bool = True,
508554
) -> list[dict]:
509555
"""Drop tools whose backing feature is unavailable/disabled, so the model is
510556
never offered a capability it can't use (defence in depth — the tool handlers
511-
also refuse). Disabling ``artifacts`` here means no persona can call it."""
557+
also refuse). Disabling ``artifacts`` here means no persona can call it. The
558+
skill-discovery tools are offered only in on-demand index mode (#50); in the
559+
default inject mode the full index is already in context, so they'd be noise."""
512560
out = tools
513561
if not secrets_available:
514562
out = [t for t in out if t["name"] not in ("list_secrets", "request_secret")]
515563
if not artifacts_enabled:
516564
out = [t for t in out if t["name"] != "write_artifact"]
565+
if not skills_on_demand:
566+
out = [t for t in out if t["name"] not in ("search_skills", "list_skills")]
517567
if not subagents_enabled:
518568
out = [t for t in out if t["name"] != "spawn_subagent"]
519569
return out
@@ -651,12 +701,7 @@ async def process(
651701
if note:
652702
preamble = f"{preamble}\n\n{note}"
653703

654-
tools = apply_feature_gates(
655-
scoped_tools(persona),
656-
secrets_available=self.secret_store is not None,
657-
artifacts_enabled=self.config.artifacts.enabled,
658-
subagents_enabled=self.config.subagents.enabled,
659-
)
704+
tools = self._tools_for_turn(persona)
660705

661706
# Static system prompt. In session mode it is snapshotted once at the
662707
# start of the session and reused for every turn (so the static content
@@ -760,6 +805,19 @@ async def bind_chat_persona_by_label(
760805
return p.name
761806
return None
762807

808+
def _tools_for_turn(self, persona: Persona | None) -> list[dict]:
809+
"""The function-tool schemas offered to the model this turn: the persona's
810+
tool scope, with feature-gated tools dropped — including the skill-discovery
811+
tools when the index is not in on-demand mode (#50). The single seam that
812+
translates ``skills_index_mode`` into the advertised tool set."""
813+
return apply_feature_gates(
814+
scoped_tools(persona),
815+
secrets_available=self.secret_store is not None,
816+
artifacts_enabled=self.config.artifacts.enabled,
817+
skills_on_demand=self.config.agent.skills_index_mode == "on_demand",
818+
subagents_enabled=self.config.subagents.enabled,
819+
)
820+
763821
async def _turn_preamble(
764822
self,
765823
decomposed_goal: DecomposedGoal | None,
@@ -804,16 +862,26 @@ async def _turn_preamble(
804862
# turns: any of those that drop or change the block simply won't find it,
805863
# and the failure direction is a harmless re-send, never a blind turn.
806864
# Injection mode and tests pass ``None`` → always include.
865+
# On-demand mode (#50): omit the full index; carry only a short, static
866+
# pointer to the search_skills/list_skills tools. The pointer is identical
867+
# every turn, so the same history gate that dedups the index also dedups it
868+
# (sent once per session, re-sent after a /new/compaction).
807869
try:
808-
skills_index = await self.skills.get_index_block(
809-
allow=persona.skills if persona else None
810-
)
811-
if skills_index:
812-
block = f"<available_skills>\n{skills_index}\n</available_skills>"
813-
if session_key is None or not await self._skills_block_in_history(
814-
session_key, block
815-
):
816-
preamble += f"\n\n{block}"
870+
if self.config.agent.skills_index_mode == "on_demand":
871+
block = f"<available_skills>\n{SKILLS_DISCOVERY_POINTER}\n</available_skills>"
872+
else:
873+
skills_index = await self.skills.get_index_block(
874+
allow=persona.skills if persona else None
875+
)
876+
block = (
877+
f"<available_skills>\n{skills_index}\n</available_skills>"
878+
if skills_index
879+
else ""
880+
)
881+
if block and (
882+
session_key is None or not await self._skills_block_in_history(session_key, block)
883+
):
884+
preamble += f"\n\n{block}"
817885
except Exception:
818886
log.exception("Failed to load skills index for turn preamble")
819887

@@ -1487,6 +1555,23 @@ async def _execute_tool(
14871555
return {"error": f"Skill not found: {skill_name}"}
14881556
return {"name": skill_name, "content": content}
14891557

1558+
if name == "search_skills":
1559+
query = str(params.get("query", "")).strip()
1560+
log.info("Tool call: search_skills — %r", query)
1561+
allowed = (request_state or {}).get("allowed_skills")
1562+
limit = params.get("limit")
1563+
try:
1564+
limit = int(limit) if limit else 10
1565+
except TypeError, ValueError:
1566+
limit = 10
1567+
matches = await self.skills.search_index(query, allow=allowed, limit=max(1, limit))
1568+
return {"skills": matches}
1569+
1570+
if name == "list_skills":
1571+
log.info("Tool call: list_skills")
1572+
allowed = (request_state or {}).get("allowed_skills")
1573+
return {"skills": await self.skills.index_entries(allow=allowed)}
1574+
14901575
if name == "recall_memory":
14911576
return await self._tool_recall_memory(params, request_state)
14921577

@@ -2107,12 +2192,9 @@ async def _run_subagent_loop(
21072192
stops at this run's step/token budget (sized by the spawning agent).
21082193
"""
21092194
cfg = self.config.subagents
2110-
tools = apply_feature_gates(
2111-
scoped_tools(child_persona),
2112-
secrets_available=self.secret_store is not None,
2113-
artifacts_enabled=self.config.artifacts.enabled,
2114-
subagents_enabled=cfg.enabled,
2115-
)
2195+
# Same gating as the main loop (incl. the #50 skill-discovery tools, which a
2196+
# subagent needs in on-demand mode — its preamble carries the pointer too).
2197+
tools = self._tools_for_turn(child_persona)
21162198
# At the depth ceiling a subagent may not spawn further — don't even offer it.
21172199
if child_state["depth"] >= cfg.recursion_depth:
21182200
tools = [t for t in tools if t["name"] != "spawn_subagent"]

core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ class AgentConfig(BaseModel):
7777
timezone: str = "Europe/Zurich"
7878
skills_dir: str = "skills/"
7979
skills_db_path: str = "data/skills.db"
80+
# How the skills index reaches the model (#50):
81+
# "inject" — the full index rides every turn's preamble (default; unchanged)
82+
# "on_demand" — the preamble omits it; the model calls search_skills/list_skills
83+
# Any unrecognised value falls back to "inject" (the safe default).
84+
skills_index_mode: str = "inject"
8085
personae_dir: str = "personae/"
8186
personae_db_path: str = "data/personae.db"
8287
active_persona: str = "" # empty = default identity (character/personalia below)

core/prompt_builder.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@
3030
You may create or update skills using the `skills.py` CLI
3131
after loading the `skill-creator` skill."""
3232

33+
# Shown instead of the full skills index when ``agent.skills_index_mode`` is
34+
# "on_demand" (#50). Mirrors the <secrets> pointer: advertise the discovery tool,
35+
# not the whole list — the model pulls matches lazily via search_skills.
36+
SKILLS_DISCOVERY_POINTER = (
37+
"Skills (reusable instructions for specific tasks) are available but not listed "
38+
"here, to keep this prompt small. When a request might need one, call the "
39+
"`search_skills` tool with a short query to find matching skills (returns name + "
40+
"summary), or `list_skills` to browse them all. Then call `load_skill` with a "
41+
"name to read that skill's full instructions before acting."
42+
)
43+
3344
DEFAULT_HISTORY_HANDLING_BLOCK = """Previous messages in this conversation
3445
have already been handled.
3546
Always focus exclusively on the latest user message as the current, active request.
@@ -117,6 +128,7 @@ def build_prompt_sections(
117128
include_memories: bool = True,
118129
include_reflections: bool = True,
119130
include_skills: bool = True,
131+
skills_on_demand: bool = False,
120132
) -> PromptSections:
121133
"""Build all prompt sections with current config and dynamic context.
122134
@@ -196,7 +208,9 @@ def build_prompt_sections(
196208
memory_section = f"<memories>\n{memories}\n</memories>"
197209

198210
skills_section = ""
199-
if include_skills and skills_index:
211+
if skills_on_demand:
212+
skills_section = f"<available_skills>\n{SKILLS_DISCOVERY_POINTER}\n</available_skills>"
213+
elif include_skills and skills_index:
200214
skills_section = f"<available_skills>\n{skills_index}\n</available_skills>"
201215

202216
reflections_section = ""

core/skills.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,23 +124,50 @@ class SkillsEngine:
124124
def __init__(self, db_path: str = "data/skills.db", seed_dir: str | Path = "skills/"):
125125
self.store = SkillsStore(db_path=db_path, seed_dir=seed_dir)
126126

127-
async def get_index_block(self, allow: list[str] | None = None) -> str:
128-
"""Render the skills index. When ``allow`` is given (a persona's
129-
allowlist), only those skills are advertised; ``None``/empty = all."""
127+
async def index_entries(self, allow: list[str] | None = None) -> list[dict]:
128+
"""The skills index as ``{name, summary}`` rows, scoped to ``allow``
129+
(a persona's allowlist; ``None``/empty = all). Backs the index block and
130+
the ``list_skills``/``search_skills`` discovery tools."""
130131
skills = await self.store.list_skills()
131132
if allow:
132133
allowed = set(allow)
133134
skills = [s for s in skills if s["name"] in allowed]
134-
if not skills:
135+
return [{"name": s["name"], "summary": (s.get("summary") or "").strip()} for s in skills]
136+
137+
async def get_index_block(self, allow: list[str] | None = None) -> str:
138+
"""Render the skills index. When ``allow`` is given (a persona's
139+
allowlist), only those skills are advertised; ``None``/empty = all."""
140+
entries = await self.index_entries(allow=allow)
141+
if not entries:
135142
return ""
136-
lines = []
137-
for skill in skills:
138-
summary = (skill.get("summary") or "").strip()
139-
if summary:
140-
lines.append(f"- {skill['name']}: {summary}")
141-
else:
142-
lines.append(f"- {skill['name']}")
143-
return "\n".join(lines)
143+
return "\n".join(
144+
f"- {e['name']}: {e['summary']}" if e["summary"] else f"- {e['name']}" for e in entries
145+
)
146+
147+
async def search_index(
148+
self, query: str, allow: list[str] | None = None, limit: int = 10
149+
) -> list[dict]:
150+
"""Top-``limit`` index entries matching ``query`` (keyword scored over
151+
name + summary), scoped to ``allow``. An empty query returns the first
152+
``limit`` entries (a cheap browse). No match → empty list.
153+
154+
ponytail: lexical scoring only; the issue defers embedding ranking until
155+
keyword search measurably falls short.
156+
"""
157+
entries = await self.index_entries(allow=allow)
158+
terms = [t for t in query.lower().split() if t]
159+
if not terms:
160+
return entries[:limit]
161+
scored = []
162+
for e in entries:
163+
haystack = f"{e['name']} {e['summary']}".lower()
164+
score = sum(haystack.count(t) for t in terms)
165+
if any(t in e["name"].lower() for t in terms):
166+
score += 5 # a name hit beats a summary hit
167+
if score:
168+
scored.append((score, e))
169+
scored.sort(key=lambda se: (-se[0], se[1]["name"]))
170+
return [e for _, e in scored[:limit]]
144171

145172
async def get_skill_content(self, name: str) -> str:
146173
skill = await self.store.get_skill(name)

docs/content/docs/architecture.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ The brain of MPA. Implements the LLM tool-use loop:
7171

7272
1. Load conversation history
7373
2. Build the static system prompt (character, personalia, active tools). In session mode this is snapshotted once per conversation (rebuilt after `/new`) and reused every turn, so the cacheable prefix stays stable; on Anthropic it is sent with a `cache_control` breakpoint so the tools + system prefix is not reprocessed each turn
74-
3. Inject the live date/time, the skills index, the fresh relevance-ranked memories + task reflections, and any per-request execution plan at the start of the current user message — so the agent always knows "now" and sees skills/memories added mid-session, without mutating the cached prefix. (The skills index and memories live here, not in the snapshot, so a skill created mid-session — e.g. via skill-creator — or a fact extracted mid-session reaches the model on the very next turn instead of waiting for `/new`.) In session mode the skills index is skipped only when that exact block is already present in the replayed history (so the model still sees it); once a `/new` or compaction drops it, or a new/rebound skill changes it, it is re-sent — so unchanged turns with the block still in history cost nothing extra.
74+
3. Inject the live date/time, the skills index, the fresh relevance-ranked memories + task reflections, and any per-request execution plan at the start of the current user message — so the agent always knows "now" and sees skills/memories added mid-session, without mutating the cached prefix. (The skills index and memories live here, not in the snapshot, so a skill created mid-session — e.g. via skill-creator — or a fact extracted mid-session reaches the model on the very next turn instead of waiting for `/new`.) In session mode the skills index is skipped only when that exact block is already present in the replayed history (so the model still sees it); once a `/new` or compaction drops it, or a new/rebound skill changes it, it is re-sent — so unchanged turns with the block still in history cost nothing extra. When `agent.skills_index_mode` is `"on_demand"` the block carries only a short pointer instead of the full list, and the model fetches entries lazily via the `search_skills`/`list_skills` tools (see [Skills](/docs/skills#serving-the-index-on-demand)) — the same lazy pattern the secrets vault uses for `list_secrets`.
7575
4. Call the LLM
7676
5. Handle tool calls with permission checks
7777
6. Save conversation turn and extract memories

docs/content/docs/configuration.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ agent:
6161
model: "claude-sonnet-4-5-20250514"
6262
timezone: "Europe/Zurich"
6363
skills_dir: "skills/"
64+
skills_index_mode: "inject" # "inject" (full index each turn) | "on_demand" (search_skills/list_skills)
6465
personae_dir: "personae/"
6566
active_persona: ""
6667

@@ -131,7 +132,7 @@ memory:
131132
132133
#### `agent`
133134

134-
Core agent settings including name, owner, LLM provider, model, and timezone. The `skills_dir` points to the directory containing markdown skill files. `personae_dir` is the starter-gallery seed directory for [personae](/docs/personae), and `active_persona` is the slug of the active persona (`""` = the default identity).
135+
Core agent settings including name, owner, LLM provider, model, and timezone. The `skills_dir` points to the directory containing markdown skill files. `skills_index_mode` chooses how the skills index reaches the model: `"inject"` (default) puts the full index in every turn's preamble, while `"on_demand"` replaces it with a short pointer and lets the model fetch entries lazily via the `search_skills`/`list_skills` tools (see [Skills](/docs/skills#serving-the-index-on-demand)). `personae_dir` is the starter-gallery seed directory for [personae](/docs/personae), and `active_persona` is the slug of the active persona (`""` = the default identity).
135136

136137
#### `channels`
137138

0 commit comments

Comments
 (0)