Replace create_skill branches with category/skill suggestion events

Navigation now offers create_category at the category level and create_skill
at the leaf. Both are stubs that log a suggestion event (state, question,
SemIf output) to the trace instead of returning an authoring sentinel.
This commit is contained in:
Denton Social
2026-09-23 18:24:13 -05:00
parent add860c0bb
commit 147b6cba5f
4 changed files with 48 additions and 15 deletions
+3 -2
View File
@@ -111,8 +111,9 @@ 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: invoke opencode to author a skill manifest at a tree - `create_skill` / `create_category` branches: navigation logs a suggestion event
leaf (currently a stub that only logs the request). (state, query, SemIf output) to the trace — currently a stub; opencode
authoring at a tree leaf is deferred.
- 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`)
+2 -2
View File
@@ -32,7 +32,7 @@ All decisions are SemIf calls: `{state, question, options[]}`. State is the curr
- **`choice`** — binary: `interrupt` / `defer`. Interrupt iff `P(interrupt) >= τ`. - **`choice`** — binary: `interrupt` / `defer`. Interrupt iff `P(interrupt) >= τ`.
- **`score`** — ordinal urgency: `critical` / `high` / `medium` / `low`, mapped to numeric weights for sorting. - **`score`** — ordinal urgency: `critical` / `high` / `medium` / `low`, mapped to numeric weights for sorting.
- **skill navigation** — at each tree level: choose category / descend / `create_skill`. - **skill navigation** — at each tree level: choose category / descend; the category level offers a `create_category` suggestion and the leaf level a `create_skill` suggestion.
- **`read_next()`** — argument selection within a skill (e.g., which contact is "girlfriend"). - **`read_next()`** — argument selection within a skill (e.g., which contact is "girlfriend").
**LLM/SemIf boundary**: SemIf for fast, repeated, low-latency decisions (gating, scoring, routing, argument selection). LLM for generation and assessment (email body, self-assessment summary). Never the reverse. **LLM/SemIf boundary**: SemIf for fast, repeated, low-latency decisions (gating, scoring, routing, argument selection). LLM for generation and assessment (email body, self-assessment summary). Never the reverse.
@@ -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.
- At each level a `create_skill` branch exists: **opencode authors the skill** (its only role) and drops a skill manifest into the registry. 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 stubs that log 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.
### 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.
+2 -3
View File
@@ -198,12 +198,11 @@ class Scheduler:
# ---- dispatch ---- # ---- dispatch ----
def _dispatch(self, request: Request) -> DispatchResult: def _dispatch(self, request: Request) -> DispatchResult:
navigation = navigate(self.engine, self.log, request, self.tree) navigation = navigate(self.engine, self.log, self.trace, request, self.tree)
if isinstance(navigation, CreateSkill): if isinstance(navigation, CreateSkill):
self.trace.append("create_skill", request.id, category=navigation.category)
return DispatchResult( return DispatchResult(
kind="create_skill", kind="create_skill",
summary="skill authoring via opencode is deferred to v2; request logged.", 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:
+41 -8
View File
@@ -1,8 +1,9 @@
"""The skill tree, registry, and SemIf-driven navigation. """The skill tree, registry, and SemIf-driven navigation.
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).
At every level a "create_skill" branch exists; opencode is the authoring tool The category level carries a "create_category" branch and the leaf level a
there (deferred to v2, stubbed as CreateSkill). "create_skill" branch; both are stubs that log a suggestion event to the trace
(deferred to v2 — no actual authoring yet).
Only the real skills live here; navigation uses the real decision engine. Only the real skills live here; navigation uses the real decision engine.
""" """
@@ -18,6 +19,7 @@ from typing import Callable
from .decisions import DecisionRequest, Option, Request from .decisions import DecisionRequest, Option, Request
from .engine import SemIfEngine from .engine import SemIfEngine
from .log import DecisionLog from .log import DecisionLog
from .trace import TraceLog
@dataclass @dataclass
@@ -56,7 +58,12 @@ class Skill:
@dataclass @dataclass
class CreateSkill: class CreateSkill:
"""Sentinel for the 'create a missing skill' branch at a tree level.""" """Stub for a missing-category/skill suggestion at a tree level.
Navigation logs the suggestion event to the trace; actual authoring is
deferred to v2. `category` is None for a new-category suggestion, else the
category that needs the new skill.
"""
category: str | None = None category: str | None = None
@@ -162,32 +169,58 @@ def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]:
def navigate( def navigate(
engine: SemIfEngine, engine: SemIfEngine,
log: DecisionLog, log: DecisionLog,
trace: TraceLog,
request: Request, request: Request,
tree: dict[str, list[Skill]], tree: dict[str, list[Skill]],
) -> Skill | CreateSkill: ) -> Skill | 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 and the leaf level a
"create_skill" branch; both log a suggestion event to the trace and return
a CreateSkill stub (actual authoring is deferred to v2).
"""
categories = sorted(tree.keys()) categories = sorted(tree.keys())
create = Option("create_skill", "Create a new skill for this.") create_category = Option("create_category", "Suggest a new category for this.")
top = DecisionRequest( top = DecisionRequest(
state=compose_state(request), state=compose_state(request),
question="Which top-level category handles this request?", question="Which top-level category handles this request?",
options=[Option(c, c) for c in categories] + [create], options=[Option(c, c) for c in categories] + [create_category],
) )
top_result = engine.call(top) top_result = engine.call(top)
log.append(top, top_result, extra={"phase": "navigate:category", "run_id": request.id}) log.append(top, top_result, extra={"phase": "navigate:category", "run_id": request.id})
category = top_result.selected category = top_result.selected
if category == "create_skill": 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 CreateSkill(category=None) return CreateSkill(category=None)
skills = tree[category] skills = tree[category]
create_skill = Option("create_skill", "Suggest creating a new skill.")
leaf = DecisionRequest( leaf = DecisionRequest(
state=compose_state(request, current=category), state=compose_state(request, current=category),
question=f"Within {category}, which skill?", question=f"Within {category}, which skill?",
options=[Option(s.name, s.description) for s in skills] + [create], options=[Option(s.name, s.description) for s in skills] + [create_skill],
) )
leaf_result = engine.call(leaf) leaf_result = engine.call(leaf)
log.append(leaf, leaf_result, extra={"phase": "navigate:leaf", "run_id": request.id}) log.append(leaf, leaf_result, extra={"phase": "navigate:leaf", "run_id": request.id})
pick = leaf_result.selected pick = leaf_result.selected
if pick == "create_skill": 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 CreateSkill(category=category)
return next(s for s in skills if s.name == pick) return next(s for s in skills if s.name == pick)