Make create_skill author a skill leaf stub like create_category

This commit is contained in:
Denton Social
2026-09-23 23:02:59 -05:00
parent f5419183bc
commit 225b4df959
6 changed files with 261 additions and 34 deletions
+8 -8
View File
@@ -23,8 +23,8 @@ scheduler.py gate -> choice(tau) -> score -> queue; preempt + requeue
queue.py urgency max-heap (desc weight, FIFO seq), age pulls toward 1.0 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 navigation = SemIf choices per level (logged), create_category
authors + registers a category stub via the decision model in and create_skill author + register stubs via the decision model
generation mode; create_skill branch (stub) in generation mode
skill.py loop: observe -> predict -> act -> observe -> assess (LLM) skill.py loop: observe -> predict -> act -> observe -> assess (LLM)
engine.py SemIfEngine -> semif_phase1.llamacpp_backend (lazy import) engine.py SemIfEngine -> semif_phase1.llamacpp_backend (lazy import)
llm.py OpenAI-compatible client for self-assessment (stdlib urllib) 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 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 pinned model revision. GPU offload: train on a beefier GPU; the running agent
keeps a frozen inference revision until a swap validates. keeps a frozen inference revision until a swap validates.
- `create_skill` branch: navigation logs a suggestion event (state, query, SemIf - `create_skill` branch: now live, mirroring `create_category`. The decision
output) to the trace — currently a stub; opencode authoring at a tree leaf is model, driven in normal generation mode via `SemIfEngine.generate`, proposes a
deferred. (`create_category` is live: the decision model, driven in normal specific skill title + description for the chosen category; the stub is
generation mode via `SemIfEngine.generate`, proposes a broad title + persisted to `data/categories.json` (under that category's `skills` list) and
description, and the stub is persisted to `data/categories.json` and merged merged into the running tree as a leaf. Still deferred: a real skill body —
into the running tree.) opencode authoring at a tree leaf remains future work.
- Queue persistence (durable across restarts). - Queue persistence (durable across restarts).
- Event/timer intake sources beyond typed input. - Event/timer intake sources beyond typed input.
- Concurrency: SemIf shared-state mode (`score_shared` / `SerialPrefixScorer`) - Concurrency: SemIf shared-state mode (`score_shared` / `SerialPrefixScorer`)
+1 -1
View File
@@ -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. - 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 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 ### Skill manifest
- name, category, description, allowed inputs, action list, cost budget, decision log reference. - name, category, description, allowed inputs, action list, cost budget, decision log reference.
+43 -6
View File
@@ -25,6 +25,8 @@ from .skills import (
build_tree, build_tree,
compose_state, compose_state,
generate_category, generate_category,
generate_skill,
merge_registry,
navigate, navigate,
) )
from .trace import TraceLog from .trace import TraceLog
@@ -80,8 +82,7 @@ class Scheduler:
self.skills = build_skills(config) self.skills = build_skills(config)
self.tree = build_tree(self.skills) self.tree = build_tree(self.skills)
self.registry = CategoryRegistry(config.get("category_registry", "data/categories.json")) self.registry = CategoryRegistry(config.get("category_registry", "data/categories.json"))
for category in self.registry.read(): merge_registry(self.tree, self.registry.read())
self.tree.setdefault(category, [])
self.ctx = ActionContext(engine=self.engine, config=config) self.ctx = ActionContext(engine=self.engine, config=config)
self.runner = SkillRunner(self.ctx, self.llm, self.log) self.runner = SkillRunner(self.ctx, self.llm, self.log)
self.current: Process | None = None self.current: Process | None = None
@@ -208,10 +209,7 @@ class Scheduler:
if isinstance(navigation, CreateCategory): if isinstance(navigation, CreateCategory):
return self._create_category(request) return self._create_category(request)
if isinstance(navigation, CreateSkill): if isinstance(navigation, CreateSkill):
return DispatchResult( return self._create_skill(request, navigation.category)
kind="create_skill",
summary="skill authoring via opencode is deferred to v2; suggestion logged.",
)
outcome = self.runner.run(navigation, request) outcome = self.runner.run(navigation, request)
if outcome.error: if outcome.error:
self.trace.append( self.trace.append(
@@ -270,6 +268,45 @@ class Scheduler:
skill=draft.name, 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: def status(self) -> str:
lines = [] lines = []
current = f"{self.current.skill} ({self.current.request.id})" if self.current else "idle" current = f"{self.current.skill} ({self.current.request.id})" if self.current else "idle"
+105 -12
View File
@@ -2,10 +2,10 @@
A skill is a leaf reached by a chain of SemIf choices (category -> skill). 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 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 "create_skill" branch. Both are live: the decision model is driven in normal
normal generation mode to propose a title + description for a broad new generation mode to propose a title + description a broad new category or a
category, which is persisted to a category registry and becomes a stub in the specific new skill leaf — which is persisted to a category registry and merged
tree. create_skill is still a stub that logs a suggestion event (deferred). into the running tree as a stub.
Only the real skills live here; navigation uses the real decision engine. Only the real skills live here; navigation uses the real decision engine.
""" """
@@ -64,8 +64,9 @@ class Skill:
class CreateSkill: class CreateSkill:
"""Suggestion that the current category needs a new skill. """Suggestion that the current category needs a new skill.
Navigation logs the suggestion event to the trace; actual authoring is Handled live, like CreateCategory: the decision model authors the new skill
deferred to v2. `category` names the category that needs the new skill. stub, which is persisted and merged into the tree. `category` names the
category that needs the new skill.
""" """
category: str category: str
@@ -88,12 +89,20 @@ class CategoryDraft:
description: str description: str
@dataclass
class SkillDraft:
"""An authored skill leaf stub: one specific action within a category."""
name: str
description: str
class CategoryRegistry: class CategoryRegistry:
"""Persisted category stubs, one file on disk. """Persisted category stubs, one file on disk.
Format: {name: {"description": str, "skills": []}}. The empty skills list is Format: {name: {"description": str, "skills": [{"name": str, "description":
the slot that create_skill will fill later; for now a stub category has no str}, ...]}}. The skills list is filled by create_skill; each entry becomes
leaves. a stub leaf merged into the running tree.
""" """
def __init__(self, path: str = "data/categories.json"): 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.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(categories, indent=2) + "\n") 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: def compose_state(request: Request, current: str | None = None) -> str:
parts = [request.text] parts = [request.text]
@@ -209,6 +228,29 @@ def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]:
return tree 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( def navigate(
engine: SemIfEngine, engine: SemIfEngine,
log: DecisionLog, log: DecisionLog,
@@ -218,9 +260,9 @@ def navigate(
) -> Skill | CreateCategory | CreateSkill: ) -> Skill | CreateCategory | CreateSkill:
"""Descend the tree one SemIf choice per level. Every choice is logged. """Descend the tree one SemIf choice per level. Every choice is logged.
The category level offers a "create_category" branch (handled live by The category level offers a "create_category" branch and the leaf level a
dispatch) and the leaf level a "create_skill" branch (still a stub); both "create_skill" branch; both are handled live by dispatch and log a
log a suggestion event to the trace. suggestion event to the trace.
""" """
categories = sorted(tree.keys()) categories = sorted(tree.keys())
create_category = Option("create_category", "Suggest a new category for this.") 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.""" """Author a new category stub with the decision model in generation mode."""
raw = engine.generate(build_category_prompt(request, tree)) raw = engine.generate(build_category_prompt(request, tree))
return parse_category_draft(raw) 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": "<short lowercase snake_case id, no spaces>", '
'"description": "<one to two sentence purpose>"}'
)
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)
+17 -1
View File
@@ -15,7 +15,7 @@ from semif_agent.cli import build_scheduler, load_config
from semif_agent.decisions import Request from semif_agent.decisions import Request
from semif_agent.dream import dream from semif_agent.dream import dream
from semif_agent.engine import EngineUnavailable 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): def require_real(config: dict):
@@ -122,3 +122,19 @@ def test_generate_category(tmp_path):
print(f"draft: {draft.name!r}{draft.description!r}") print(f"draft: {draft.name!r}{draft.description!r}")
assert isinstance(draft, CategoryDraft) assert isinstance(draft, CategoryDraft)
assert draft.name and draft.description 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
+85 -4
View File
@@ -1,5 +1,5 @@
"""Pure-stdlib tests for skill-tree authoring: category prompts, draft parsing, """Pure-stdlib tests for skill-tree authoring: category and skill prompts, draft
the category registry, and the error path when the engine is unavailable. 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 No mocking: engine-dependent success paths are exercised only by the box
integration tests against the real decision model. integration tests against the real decision model.
@@ -12,11 +12,16 @@ from semif_agent.engine import EngineConfig, EngineUnavailable, SemIfEngine
from semif_agent.skills import ( from semif_agent.skills import (
CategoryDraft, CategoryDraft,
CategoryRegistry, CategoryRegistry,
SkillDraft,
build_category_prompt, build_category_prompt,
build_skill_prompt,
build_skills, build_skills,
build_tree, build_tree,
generate_category, generate_category,
generate_skill,
merge_registry,
parse_category_draft, 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 = CategoryRegistry(str(tmp_path / "categories.json"))
registry.register("delivery", "Track and manage package deliveries.") registry.register("delivery", "Track and manage package deliveries.")
tree = build_tree(build_skills({"skills": {}})) tree = build_tree(build_skills({"skills": {}}))
for category in registry.read(): merge_registry(tree, registry.read())
tree.setdefault(category, [])
assert tree["delivery"] == [] 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"