From 225b4df959c56d9743cb554c6083c1c273508ceb Mon Sep 17 00:00:00 2001 From: Denton Social Date: Wed, 23 Sep 2026 23:02:59 -0500 Subject: [PATCH] Make create_skill author a skill leaf stub like create_category --- AGENTS.md | 18 ++--- IDEA.md | 2 +- semif_agent/scheduler.py | 49 ++++++++++-- semif_agent/skills.py | 117 ++++++++++++++++++++++++++--- tests/integration/test_pipeline.py | 18 ++++- tests/test_skills.py | 91 ++++++++++++++++++++-- 6 files changed, 261 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 747d5a3..63e55be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,10 +21,10 @@ cli.py argparse: run (REPL / --script), dream, skills, status, relabel, dashboard scheduler.py gate -> choice(tau) -> score -> queue; preempt + requeue queue.py urgency max-heap (desc weight, FIFO seq), age pulls toward 1.0 -skills.py tree + registry (email.compose, response.reject, tracking.check), +skills.py tree + registry (email.compose, response.reject, tracking.check), navigation = SemIf choices per level (logged), create_category - authors + registers a category stub via the decision model in - generation mode; create_skill branch (stub) + and create_skill author + register stubs via the decision model + in generation mode skill.py loop: observe -> predict -> act -> observe -> assess (LLM) engine.py SemIfEngine -> semif_phase1.llamacpp_backend (lazy import) llm.py OpenAI-compatible client for self-assessment (stdlib urllib) @@ -116,12 +116,12 @@ unit tests (24) + box integration tests (2). validate (accuracy/ECE on a held-out slice, prompt-hash regression), swap the pinned model revision. GPU offload: train on a beefier GPU; the running agent keeps a frozen inference revision until a swap validates. -- `create_skill` branch: navigation logs a suggestion event (state, query, SemIf - output) to the trace — currently a stub; opencode authoring at a tree leaf is - deferred. (`create_category` is live: the decision model, driven in normal - generation mode via `SemIfEngine.generate`, proposes a broad title + - description, and the stub is persisted to `data/categories.json` and merged - into the running tree.) +- `create_skill` branch: now live, mirroring `create_category`. The decision + model, driven in normal generation mode via `SemIfEngine.generate`, proposes a + specific skill title + description for the chosen category; the stub is + persisted to `data/categories.json` (under that category's `skills` list) and + merged into the running tree as a leaf. Still deferred: a real skill body — + opencode authoring at a tree leaf remains future work. - Queue persistence (durable across restarts). - Event/timer intake sources beyond typed input. - Concurrency: SemIf shared-state mode (`score_shared` / `SerialPrefixScorer`) diff --git a/IDEA.md b/IDEA.md index 1970d8c..f7b09ef 100644 --- a/IDEA.md +++ b/IDEA.md @@ -41,7 +41,7 @@ All decisions are SemIf calls: `{state, question, options[]}`. State is the curr - Structure: categories → skills → actions. Top level listed at each level. - Navigation is a chain of SemIf choices, one per level, descending until a leaf skill matches. -- Navigation offers a `create_category` suggestion at the category level and a `create_skill` suggestion at the leaf level. `create_category` is live: the decision model, driven in normal generation mode, authors a broad title + description, and the stub is persisted to the category registry and merged into the running tree. `create_skill` logs a suggestion event (state, query, SemIf output) to the trace — **opencode authors the skill** (its only role) and drops a skill manifest into the registry, deferred to v2. The new skill becomes a leaf immediately. +- Navigation offers a `create_category` suggestion at the category level and a `create_skill` suggestion at the leaf level. Both are live: the decision model, driven in normal generation mode, authors a title + description (broad bucket for a category, single specific action for a skill), and the stub is persisted to the category registry and merged into the running tree. The new skill becomes a leaf immediately; a real skill body (opencode authoring a skill manifest) remains deferred to v2. ### Skill manifest - name, category, description, allowed inputs, action list, cost budget, decision log reference. diff --git a/semif_agent/scheduler.py b/semif_agent/scheduler.py index 65f71ba..3778690 100644 --- a/semif_agent/scheduler.py +++ b/semif_agent/scheduler.py @@ -25,6 +25,8 @@ from .skills import ( build_tree, compose_state, generate_category, + generate_skill, + merge_registry, navigate, ) from .trace import TraceLog @@ -80,8 +82,7 @@ class Scheduler: self.skills = build_skills(config) self.tree = build_tree(self.skills) self.registry = CategoryRegistry(config.get("category_registry", "data/categories.json")) - for category in self.registry.read(): - self.tree.setdefault(category, []) + merge_registry(self.tree, self.registry.read()) self.ctx = ActionContext(engine=self.engine, config=config) self.runner = SkillRunner(self.ctx, self.llm, self.log) self.current: Process | None = None @@ -208,10 +209,7 @@ class Scheduler: if isinstance(navigation, CreateCategory): return self._create_category(request) if isinstance(navigation, CreateSkill): - return DispatchResult( - kind="create_skill", - summary="skill authoring via opencode is deferred to v2; suggestion logged.", - ) + return self._create_skill(request, navigation.category) outcome = self.runner.run(navigation, request) if outcome.error: self.trace.append( @@ -270,6 +268,45 @@ class Scheduler: skill=draft.name, ) + def _create_skill(self, request: Request, category: str) -> DispatchResult: + """Author a new skill leaf stub with the decision model in generation mode.""" + from .engine import EngineUnavailable + + try: + draft = generate_skill(self.engine, request, category, self.tree) + except (EngineUnavailable, ValueError) as exc: + self.trace.append("error", request.id, phase="create_skill", message=str(exc)) + return DispatchResult(kind="error", summary=f"create_skill failed: {exc}") + existing = {s.name for s in self.tree.get(category, [])} + if draft.name in existing: + self.trace.append( + "error", + request.id, + phase="create_skill", + category=category, + message=f"skill {draft.name} already exists", + ) + return DispatchResult( + kind="error", + summary=f"create_skill failed: {draft.name} already exists", + ) + self.registry.register_skill(category, draft.name, draft.description) + self.tree.setdefault(category, []).append( + Skill(name=draft.name, category=category, description=draft.description) + ) + self.trace.append( + "skill_created", + request.id, + category=category, + skill=draft.name, + description=draft.description, + ) + return DispatchResult( + kind="create_skill", + summary=f"created skill {category}.{draft.name}: {draft.description}", + skill=draft.name, + ) + def status(self) -> str: lines = [] current = f"{self.current.skill} ({self.current.request.id})" if self.current else "idle" diff --git a/semif_agent/skills.py b/semif_agent/skills.py index f6e405a..4d509c8 100644 --- a/semif_agent/skills.py +++ b/semif_agent/skills.py @@ -2,10 +2,10 @@ 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. create_category is live: the decision model is driven in -normal generation mode to propose a title + description for a broad new -category, which is persisted to a category registry and becomes a stub in the -tree. create_skill is still a stub that logs a suggestion event (deferred). +"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. """ @@ -64,8 +64,9 @@ class Skill: class CreateSkill: """Suggestion that the current category needs a new skill. - Navigation logs the suggestion event to the trace; actual authoring is - deferred to v2. `category` names the category that needs the 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 @@ -88,12 +89,20 @@ class CategoryDraft: description: str +@dataclass +class SkillDraft: + """An authored skill leaf stub: one specific action within a category.""" + + name: str + description: str + + class CategoryRegistry: """Persisted category stubs, one file on disk. - Format: {name: {"description": str, "skills": []}}. The empty skills list is - the slot that create_skill will fill later; for now a stub category has no - leaves. + 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"): @@ -110,6 +119,16 @@ class CategoryRegistry: 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") + def compose_state(request: Request, current: str | None = None) -> str: parts = [request.text] @@ -209,6 +228,29 @@ def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]: 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, @@ -218,9 +260,9 @@ def navigate( ) -> Skill | CreateCategory | CreateSkill: """Descend the tree one SemIf choice per level. Every choice is logged. - The category level offers a "create_category" branch (handled live by - dispatch) and the leaf level a "create_skill" branch (still a stub); both - log a suggestion event to the trace. + 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. """ categories = sorted(tree.keys()) create_category = Option("create_category", "Suggest a new category for this.") @@ -322,3 +364,54 @@ def generate_category( """Author a new category stub with the decision model in generation mode.""" raw = engine.generate(build_category_prompt(request, tree)) 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 name.replace("_", "").isalnum(): + raise ValueError(f"skill title must be snake_case alnum: {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)) + return parse_skill_draft(raw) diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 4541fce..13a7774 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -15,7 +15,7 @@ from semif_agent.cli import build_scheduler, load_config from semif_agent.decisions import Request from semif_agent.dream import dream from semif_agent.engine import EngineUnavailable -from semif_agent.skills import CategoryDraft, generate_category +from semif_agent.skills import CategoryDraft, SkillDraft, generate_category, generate_skill def require_real(config: dict): @@ -121,4 +121,20 @@ def test_generate_category(tmp_path): ) print(f"draft: {draft.name!r} — {draft.description!r}") assert isinstance(draft, CategoryDraft) + assert draft.name and draft.description + + +def test_generate_skill(tmp_path): + """Authoring a skill leaf stub through the real decision model.""" + config = load_config() + require_real(config) + scheduler, config = build_scheduler(config) + draft = generate_skill( + scheduler.engine, + Request("tell me if my package was delivered"), + "tracking", + scheduler.tree, + ) + print(f"draft: {draft.name!r} — {draft.description!r}") + assert isinstance(draft, SkillDraft) assert draft.name and draft.description \ No newline at end of file diff --git a/tests/test_skills.py b/tests/test_skills.py index 78eb6b6..cd24901 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,5 +1,5 @@ -"""Pure-stdlib tests for skill-tree authoring: category prompts, draft parsing, -the category registry, and the error path when the engine is unavailable. +"""Pure-stdlib tests for skill-tree authoring: category and skill prompts, draft +parsing, the category registry, and the error path when the engine is unavailable. No mocking: engine-dependent success paths are exercised only by the box integration tests against the real decision model. @@ -12,11 +12,16 @@ from semif_agent.engine import EngineConfig, EngineUnavailable, SemIfEngine from semif_agent.skills import ( CategoryDraft, CategoryRegistry, + SkillDraft, build_category_prompt, + build_skill_prompt, build_skills, build_tree, generate_category, + generate_skill, + merge_registry, parse_category_draft, + parse_skill_draft, ) @@ -94,6 +99,82 @@ def test_build_tree_includes_registry_stubs(tmp_path): registry = CategoryRegistry(str(tmp_path / "categories.json")) registry.register("delivery", "Track and manage package deliveries.") tree = build_tree(build_skills({"skills": {}})) - for category in registry.read(): - tree.setdefault(category, []) - assert tree["delivery"] == [] \ No newline at end of file + merge_registry(tree, registry.read()) + assert tree["delivery"] == [] + + +def test_build_skill_prompt_contains_request_category_and_skills(): + tree = build_tree(build_skills({"skills": {}})) + messages = build_skill_prompt(Request("tracking for my drone delivery"), "tracking", tree) + assert messages[0]["role"] == "system" + assert "tracking" in messages[0]["content"] + joined = messages[1]["content"] + assert "tracking for my drone delivery" in joined + assert "tracking.check" in joined + + +def test_parse_skill_draft_plain_json(): + draft = parse_skill_draft( + '{"title": "track_live", "description": "Follow a package in real time."}' + ) + assert draft.name == "track_live" + assert "real time" in draft.description + + +def test_parse_skill_draft_json_in_prose(): + draft = parse_skill_draft( + 'Here you go:\n{"title": "resend_email", "description": "Re-send a failed ' + 'email draft."}\nHope that helps.' + ) + assert draft.name == "resend_email" + + +def test_parse_skill_draft_normalizes_title(): + draft = parse_skill_draft( + '{"title": "Live Tracking", "description": "Follow a package in real time."}' + ) + assert draft.name == "live_tracking" + + +def test_parse_skill_draft_missing_fields_raises(): + with pytest.raises(ValueError): + parse_skill_draft('{"title": "only_title"}') + with pytest.raises(ValueError): + parse_skill_draft("not json at all") + + +def test_parse_skill_draft_rejects_unclean_title(): + with pytest.raises(ValueError): + parse_skill_draft('{"title": "ca$h!", "description": "nope"}') + + +def test_generate_skill_without_engine_raises(): + engine = SemIfEngine(EngineConfig()) + with pytest.raises(EngineUnavailable): + generate_skill(engine, Request("anything"), "tracking", {}) + + +def test_category_registry_register_skill(tmp_path): + registry = CategoryRegistry(str(tmp_path / "categories.json")) + registry.register("delivery", "Track and manage package deliveries.") + registry.register_skill("delivery", "track_live", "Follow a package in real time.") + registry.register_skill("delivery", "track_live", "Duplicate, ignored.") + registry.register_skill("brand_new", "ping", "Probe the service.") + loaded = registry.read() + assert loaded["delivery"]["skills"] == [ + {"name": "track_live", "description": "Follow a package in real time."} + ] + assert loaded["brand_new"] == { + "description": "", + "skills": [{"name": "ping", "description": "Probe the service."}], + } + + +def test_merge_registry_loads_categories_and_skills(tmp_path): + registry = CategoryRegistry(str(tmp_path / "categories.json")) + registry.register_skill("delivery", "track_live", "Follow a package in real time.") + tree = build_tree(build_skills({"skills": {}})) + merge_registry(tree, registry.read()) + names = [s.name for s in tree["delivery"]] + assert names == ["track_live"] + assert tree["delivery"][0].category == "delivery" \ No newline at end of file