Make create_skill author a skill leaf stub like create_category
This commit is contained in:
@@ -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`)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
Reference in New Issue
Block a user