"""The skill tree, registry, and SemIf-driven navigation. A skill is a leaf reached by a chain of SemIf choices (category -> skill). The category level carries a "create_category" branch and the leaf level a "create_skill" branch. Both are live: the decision model is driven in normal generation mode to propose a title + description — a broad new category or a specific new skill leaf — which is persisted to a category registry and merged into the running tree as a stub. Only the real skills live here; navigation uses the real decision engine. """ from __future__ import annotations import importlib.util import json import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Callable from .decisions import DecisionRequest, Option, Request from .engine import SemIfEngine from .llm import LLMClient from .log import DecisionLog from .trace import TraceLog @dataclass class ActionResult: action_log: str new_state: str needs_input: str | None = None @dataclass class Prediction: """The predict phase: a forecast plus any SemIf decisions it made.""" text: str decisions: list[tuple[DecisionRequest, object]] = field(default_factory=list) @dataclass class ActionContext: engine: SemIfEngine config: dict @dataclass class Skill: name: str category: str description: str cost_budget: float = 1.0 predict: Callable[[ActionContext, Request], Prediction] = field( default=lambda ctx, req: Prediction(text="") ) act: Callable[[ActionContext, Request, Prediction], ActionResult] = field( default=lambda ctx, req, pred: ActionResult("", "") ) @dataclass class CreateSkill: """Suggestion that the current category needs a new skill. Handled live, like CreateCategory: the decision model authors the new skill stub, which is persisted and merged into the tree. `category` names the category that needs the new skill. """ category: str @dataclass class CreateCategory: """Suggestion that the request needs a brand-new top-level category. Unlike CreateSkill this is handled live: the decision model is used in normal generation mode to author the category stub. """ @dataclass class CategoryDraft: """An authored category stub: a broad bucket for future skills.""" name: str description: str @dataclass class SkillDraft: """An authored skill leaf stub: one specific action within a category.""" name: str description: str code: str = "" class CategoryRegistry: """Persisted category stubs, one file on disk. Format: {name: {"description": str, "skills": [{"name": str, "description": str}, ...]}}. The skills list is filled by create_skill; each entry becomes a stub leaf merged into the running tree. """ def __init__(self, path: str = "data/categories.json"): self.path = Path(path) def read(self) -> dict[str, dict]: if not self.path.is_file(): return {} return json.loads(self.path.read_text()) def register(self, name: str, description: str) -> None: categories = self.read() categories[name] = {"description": description, "skills": []} self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_text(json.dumps(categories, indent=2) + "\n") def register_skill(self, category: str, name: str, description: str) -> None: """Add a skill leaf to a category, creating the category entry if needed.""" categories = self.read() entry = categories.setdefault(category, {"description": "", "skills": []}) skills = entry.setdefault("skills", []) if not any(s.get("name") == name for s in skills): skills.append({"name": name, "description": description}) self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_text(json.dumps(categories, indent=2) + "\n") class SkillBodyStore: """Persists runnable skill bodies as one Python file per skill. Layout: //.py. Bodies are written by the codegen step and loaded back at startup so skills stay runnable across restarts. """ def __init__(self, path: str = "data/skills"): self.path = Path(path) def write(self, category: str, name: str, code: str) -> Path: directory = self.path / category directory.mkdir(parents=True, exist_ok=True) target = directory / f"{name}.py" target.write_text(code.rstrip() + "\n") return target def body_path(self, category: str, name: str) -> Path: return self.path / category / f"{name}.py" def list_bodies(self) -> list[tuple[str, str]]: if not self.path.is_dir(): return [] bodies = [] for directory in sorted(self.path.iterdir()): if not directory.is_dir(): continue for module in sorted(directory.glob("*.py")): bodies.append((directory.name, module.stem)) return bodies def load_skill_module(category: str, name: str, base: str = "data/skills"): """Import a persisted skill body and return its module.""" path = Path(base) / category / f"{name}.py" module_name = f"_skill_{category}_{name}".replace("-", "_") spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise ValueError(f"cannot load skill module: {path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def materialize_skill( draft: SkillDraft, category: str, store: SkillBodyStore ) -> Skill: """Persist the draft's code body and build a runnable Skill from it.""" if not draft.code: raise ValueError(f"skill {draft.name} has no code body to materialize") store.write(category, draft.name, draft.code) try: module = load_skill_module(category, draft.name, store.path) except Exception as exc: raise ValueError(f"skill {category}.{draft.name} body failed to import: {exc}") from exc if not callable(getattr(module, "predict", None)) or not callable( getattr(module, "act", None) ): raise ValueError(f"skill {category}.{draft.name} body must define predict and act") return Skill( name=draft.name, category=category, description=draft.description, predict=module.predict, act=module.act, ) def merge_skill_bodies( tree: dict[str, list[Skill]], store: SkillBodyStore, registry: dict[str, dict] ) -> int: """Upgrade persisted skill bodies in the tree to runnable skills. A body file makes a stub leaf executable; where the registry entry was lost (or never written), the category is created and the description falls back to the skill name. Returns the number of skills made runnable. """ upgraded = 0 for category, name in store.list_bodies(): description = "" entry = registry.get(category, {}) for skill in entry.get("skills", []): if skill.get("name") == name: description = skill.get("description", "") try: module = load_skill_module(category, name, store.path) except Exception: continue skill = Skill( name=name, category=category, description=description or name, predict=module.predict, act=module.act, ) skills = tree.setdefault(category, []) for index, existing in enumerate(skills): if existing.name == name: skills[index] = skill break else: skills.append(skill) skills.sort(key=lambda s: s.name) upgraded += 1 return upgraded def compose_state(request: Request, current: str | None = None) -> str: parts = [request.text] if current: parts.append(f"[current process: {current}]") return " ".join(parts) def _contacts(ctx: ActionContext) -> list[dict]: path = Path(ctx.config.get("contacts", "data/contacts.json")) if not path.is_file(): return [] return json.loads(path.read_text()) def _email_predict(ctx: ActionContext, request: Request) -> Prediction: contacts = _contacts(ctx) if not contacts: return Prediction(text="no contacts available", decisions=[]) decision = DecisionRequest( state=compose_state(request), question="Which contact is the intended recipient?", options=[Option(c["name"], c.get("description", "")) for c in contacts] + [Option("none", "None of the listed contacts.")], ) result = ctx.engine.call(decision) return Prediction(text=f"recipient is {result.selected}", decisions=[(decision, result)]) def _email_compose(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult: recipient = prediction.text.removeprefix("recipient is ") if recipient == "no contacts available" or recipient == "none": return ActionResult( action_log="email.compose aborted: recipient not resolved.", new_state=request.text, ) drafts = Path(ctx.config.get("drafts", "data/drafts")) drafts.mkdir(parents=True, exist_ok=True) target = drafts / f"{request.id}.txt" target.write_text(f"To: {recipient}\nBody: {request.text}\n") return ActionResult( action_log=f"email.compose: wrote draft {target} for {recipient!r}.", new_state=f"Draft written to {target.name} for {recipient}.", ) def _response_reject(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult: message = f"Rejected: I cannot act on this while busy ({request.text})." return ActionResult(action_log=f"response.reject: {message}", new_state=message) def _tracking_check(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult: path = Path(ctx.config.get("packages", "data/packages.json")) if not path.is_file(): return ActionResult( action_log="tracking.check aborted: no packages file.", new_state=request.text, ) packages = json.loads(path.read_text()) lines = [f"{p.get('id')}: {p.get('status')}" for p in packages] report = "Tracking statuses:\n" + "\n".join(lines) return ActionResult(action_log="tracking.check: " + report, new_state=report) def build_skills(config: dict) -> list[Skill]: skills = config.get("skills", {}) return [ Skill( name="email.compose", category="email", description="Compose and dispatch an email.", predict=_email_predict, act=_email_compose, cost_budget=float(skills.get("email", {}).get("cost_budget", 1.0)), ), Skill( name="response.reject", category="response", description="Politely reject a request because the agent is busy.", act=_response_reject, ), Skill( name="tracking.check", category="tracking", description="Check the delivery status of a package.", act=_tracking_check, ), ] def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]: tree: dict[str, list[Skill]] = {} for skill in skills: tree.setdefault(skill.category, []).append(skill) for category in tree: tree[category].sort(key=lambda s: s.name) return tree def merge_registry(tree: dict[str, list[Skill]], categories: dict[str, dict]) -> None: """Fold persisted categories and their skills into a running tree. Category stubs become empty buckets; registered skills become stub leaves (no-op bodies) so they are navigable and rerunnable immediately. """ for category, data in categories.items(): tree.setdefault(category, []) existing = {s.name for s in tree[category]} for skill in data.get("skills", []): name = skill.get("name") if not name or name in existing: continue tree[category].append( Skill( name=name, category=category, description=skill.get("description", ""), ) ) existing.add(name) def navigate( engine: SemIfEngine, log: DecisionLog, trace: TraceLog, request: Request, tree: dict[str, list[Skill]], ) -> Skill | CreateCategory | CreateSkill: """Descend the tree one SemIf choice per level. Every choice is logged. The category level offers a "create_category" branch and the leaf level a "create_skill" branch; both are handled live by dispatch and log a suggestion event to the trace. A level with nothing to choose from (an empty tree, or a category with no skills yet) short-circuits straight to the create branch: SemIf decisions need at least two options, and asking "which of one?" is meaningless. """ categories = sorted(tree.keys()) create_category = Option("create_category", "Suggest a new category for this.") top = DecisionRequest( state=compose_state(request), question="Which top-level category handles this request?", options=[Option(c, c) for c in categories] + [create_category], ) if not categories: trace.append( "create_category", request.id, state=top.state, question=top.question, options=[o.id for o in top.options], selected="create_category", probs={}, ) return CreateCategory() top_result = engine.call(top) log.append(top, top_result, extra={"phase": "navigate:category", "run_id": request.id}) category = top_result.selected if category == "create_category": trace.append( "create_category", request.id, state=top.state, question=top.question, options=[o.id for o in top.options], selected=top_result.selected, probs=top_result.probs, ) return CreateCategory() skills = tree[category] create_skill = Option("create_skill", "Suggest creating a new skill.") leaf = DecisionRequest( state=compose_state(request, current=category), question=f"Within {category}, which skill?", options=[Option(s.name, s.description) for s in skills] + [create_skill], ) if not skills: trace.append( "skill_needed", request.id, category=category, state=leaf.state, question=leaf.question, options=[o.id for o in leaf.options], selected="create_skill", probs={}, ) return CreateSkill(category=category) leaf_result = engine.call(leaf) log.append(leaf, leaf_result, extra={"phase": "navigate:leaf", "run_id": request.id}) pick = leaf_result.selected if pick == "create_skill": trace.append( "skill_needed", request.id, category=category, state=leaf.state, question=leaf.question, options=[o.id for o in leaf.options], selected=leaf_result.selected, probs=leaf_result.probs, ) return CreateSkill(category=category) return next(s for s in skills if s.name == pick) def tree_summary(tree: dict[str, list[Skill]]) -> str: lines = [] for category in sorted(tree): names = ", ".join(s.name for s in tree[category]) lines.append(f" {category}: {names}") return "\n".join(lines) def build_category_prompt(request: Request, tree: dict[str, list[Skill]]) -> list[dict]: """Chat messages for the decision model used as the category author. The category must be a general bucket that many tools could fit under, not a single skill. The existing tree is included so the model avoids duplicating categories and stays broad enough to be useful. """ system = ( "You are the skill-tree authoring step of a local agent. A request did " "not fit any existing category. Propose one new top-level category of " "tools/skills that would encompass this request. It must be broad enough " "that many tools could fit under it — a general-purpose bucket, not a " "single skill. Reply with JSON only: " '{"title": "", ' '"description": ""}' ) user = ( f"Request: {request.text}\n" f"Existing categories and their skills:\n{tree_summary(tree)}\n" "Proposed new category (JSON only):" ) return [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] def parse_category_draft(raw: str) -> CategoryDraft: """Parse the model's JSON reply into a CategoryDraft.""" parsed = LLMClient._parse_json(raw) title = str(parsed.get("title", "")).strip() description = str(parsed.get("description", "")).strip() if not title or not description: raise ValueError(f"category draft missing title/description: {raw!r}") name = re.sub(r"\s+", "_", title.lower()) if not name.replace("_", "").isalnum(): raise ValueError(f"category title must be snake_case alnum: {title!r}") return CategoryDraft(name=name, description=description) def generate_category( engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]] ) -> CategoryDraft: """Author a new category stub with the decision model in generation mode.""" raw = engine.generate(build_category_prompt(request, tree), max_tokens=128) return parse_category_draft(raw) def build_skill_prompt( request: Request, category: str, tree: dict[str, list[Skill]] ) -> list[dict]: """Chat messages for the decision model used as the skill author. The skill must be one specific, single-purpose action that fits inside the given category — not a broad bucket. Existing skills in the category are included so the model avoids duplicating them. """ system = ( "You are the skill-tree authoring step of a local agent. A request inside " f"the '{category}' category did not fit any existing skill. Propose ONE " "new skill for this category: a specific, single-purpose action the agent " "can take. Reply with JSON only: " '{"title": "", ' '"description": ""}' ) existing = ", ".join(s.name for s in tree.get(category, [])) or "(none)" user = ( f"Request: {request.text}\n" f"Category: {category}\n" f"Existing skills in this category: {existing}\n" "Proposed new skill (JSON only):" ) return [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] def parse_skill_draft(raw: str) -> SkillDraft: """Parse the model's JSON reply into a SkillDraft.""" parsed = LLMClient._parse_json(raw) title = str(parsed.get("title", "")).strip() description = str(parsed.get("description", "")).strip() if not title or not description: raise ValueError(f"skill draft missing title/description: {raw!r}") name = re.sub(r"\s+", "_", title.lower()) if not re.fullmatch(r"[a-z0-9_]+(?:\.[a-z0-9_]+)*", name): raise ValueError(f"skill title must be snake_case alnum (dots allowed): {title!r}") return SkillDraft(name=name, description=description) def generate_skill( engine: SemIfEngine, request: Request, category: str, tree: dict[str, list[Skill]] ) -> SkillDraft: """Author a new skill leaf stub with the decision model in generation mode.""" raw = engine.generate(build_skill_prompt(request, category, tree), max_tokens=128) return parse_skill_draft(raw)