Fix create_skill wedge: short-circuit single-option navigation, never leave scheduler busy
An empty category produced a one-option SemIf leaf decision; the backend rejects <2 options, the ValueError unwound past the current-process reset, and every later request queued forever behind a phantom current. Navigation now short-circuits empty trees/categories straight to the create branch (no SemIf decision when there's nothing to choose), and the scheduler clears its current process even when dispatch raises. Authoring generation is capped at 128 tokens.
This commit is contained in:
@@ -152,8 +152,10 @@ class Scheduler:
|
|||||||
if self.current is None:
|
if self.current is None:
|
||||||
weight, label = self._score(request)
|
weight, label = self._score(request)
|
||||||
self.current = Process(request=request, skill="(scheduling)", weight=weight)
|
self.current = Process(request=request, skill="(scheduling)", weight=weight)
|
||||||
outcome = self._dispatch(request)
|
try:
|
||||||
self.current = None
|
outcome = self._dispatch(request)
|
||||||
|
finally:
|
||||||
|
self.current = None
|
||||||
self.trace.append("ran", request.id, skill=outcome.skill, summary=outcome.summary)
|
self.trace.append("ran", request.id, skill=outcome.skill, summary=outcome.summary)
|
||||||
return "running", f"[{label}] {outcome.summary}"
|
return "running", f"[{label}] {outcome.summary}"
|
||||||
|
|
||||||
@@ -197,7 +199,11 @@ class Scheduler:
|
|||||||
outcome = self._dispatch(request)
|
outcome = self._dispatch(request)
|
||||||
except EngineUnavailable as exc:
|
except EngineUnavailable as exc:
|
||||||
outcome = DispatchResult(kind="error", summary=f"engine unavailable: {exc}")
|
outcome = DispatchResult(kind="error", summary=f"engine unavailable: {exc}")
|
||||||
self.current = None
|
except Exception as exc:
|
||||||
|
self.trace.append("error", request.id, phase="dispatch", message=str(exc))
|
||||||
|
outcome = DispatchResult(kind="error", summary=f"dispatch failed: {exc}")
|
||||||
|
finally:
|
||||||
|
self.current = None
|
||||||
self.trace.append("ran", request.id, skill=outcome.skill, summary=outcome.summary)
|
self.trace.append("ran", request.id, skill=outcome.skill, summary=outcome.summary)
|
||||||
results.append(("ran", f"[{request.id}] {outcome.summary}"))
|
results.append(("ran", f"[{request.id}] {outcome.summary}"))
|
||||||
return results
|
return results
|
||||||
|
|||||||
+29
-3
@@ -262,7 +262,10 @@ def navigate(
|
|||||||
|
|
||||||
The category level offers a "create_category" branch and the leaf level a
|
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
|
"create_skill" branch; both are handled live by dispatch and log a
|
||||||
suggestion event to the trace.
|
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())
|
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.")
|
||||||
@@ -271,6 +274,17 @@ def navigate(
|
|||||||
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_category],
|
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)
|
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
|
||||||
@@ -292,6 +306,18 @@ def navigate(
|
|||||||
question=f"Within {category}, which skill?",
|
question=f"Within {category}, which skill?",
|
||||||
options=[Option(s.name, s.description) for s in skills] + [create_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)
|
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
|
||||||
@@ -362,7 +388,7 @@ def generate_category(
|
|||||||
engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]]
|
engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]]
|
||||||
) -> CategoryDraft:
|
) -> CategoryDraft:
|
||||||
"""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), max_tokens=128)
|
||||||
return parse_category_draft(raw)
|
return parse_category_draft(raw)
|
||||||
|
|
||||||
|
|
||||||
@@ -413,5 +439,5 @@ def generate_skill(
|
|||||||
engine: SemIfEngine, request: Request, category: str, tree: dict[str, list[Skill]]
|
engine: SemIfEngine, request: Request, category: str, tree: dict[str, list[Skill]]
|
||||||
) -> SkillDraft:
|
) -> SkillDraft:
|
||||||
"""Author a new skill leaf stub with the decision model in generation mode."""
|
"""Author a new skill leaf stub with the decision model in generation mode."""
|
||||||
raw = engine.generate(build_skill_prompt(request, category, tree))
|
raw = engine.generate(build_skill_prompt(request, category, tree), max_tokens=128)
|
||||||
return parse_skill_draft(raw)
|
return parse_skill_draft(raw)
|
||||||
|
|||||||
@@ -137,4 +137,30 @@ def test_generate_skill(tmp_path):
|
|||||||
)
|
)
|
||||||
print(f"draft: {draft.name!r} — {draft.description!r}")
|
print(f"draft: {draft.name!r} — {draft.description!r}")
|
||||||
assert isinstance(draft, SkillDraft)
|
assert isinstance(draft, SkillDraft)
|
||||||
assert draft.name and draft.description
|
assert draft.name and draft.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_skill_empty_category_does_not_wedge(tmp_path):
|
||||||
|
"""A dispatch that lands on an empty category must not leave the scheduler wedged.
|
||||||
|
|
||||||
|
Regression: navigation on a category with no skills produced a single-option
|
||||||
|
SemIf decision, which the backend rejects; the exception unwound past the
|
||||||
|
current-process reset, so every later request queued forever behind a phantom
|
||||||
|
current. The empty category must now short-circuit straight to create_skill.
|
||||||
|
"""
|
||||||
|
config = load_config()
|
||||||
|
require_real(config)
|
||||||
|
config["log"] = str(tmp_path / "decisions.jsonl")
|
||||||
|
config["trace"] = str(tmp_path / "runs.jsonl")
|
||||||
|
scheduler, config = build_scheduler(config)
|
||||||
|
scheduler.tree["travel_planning"] = []
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
status, detail = scheduler.submit("look up flights to japan for february")
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
assert status in ("running", "preempted", "queued", "rejected", "error")
|
||||||
|
assert scheduler.current is None, "scheduler must never stay wedged after a submit"
|
||||||
|
|
||||||
|
status, detail = scheduler.submit("tell me if my package was delivered")
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
assert scheduler.current is None
|
||||||
+15
-1
@@ -9,9 +9,11 @@ import pytest
|
|||||||
|
|
||||||
from semif_agent.decisions import Request
|
from semif_agent.decisions import Request
|
||||||
from semif_agent.engine import EngineConfig, EngineUnavailable, SemIfEngine
|
from semif_agent.engine import EngineConfig, EngineUnavailable, SemIfEngine
|
||||||
|
from semif_agent.log import DecisionLog
|
||||||
from semif_agent.skills import (
|
from semif_agent.skills import (
|
||||||
CategoryDraft,
|
CategoryDraft,
|
||||||
CategoryRegistry,
|
CategoryRegistry,
|
||||||
|
CreateCategory,
|
||||||
SkillDraft,
|
SkillDraft,
|
||||||
build_category_prompt,
|
build_category_prompt,
|
||||||
build_skill_prompt,
|
build_skill_prompt,
|
||||||
@@ -20,9 +22,11 @@ from semif_agent.skills import (
|
|||||||
generate_category,
|
generate_category,
|
||||||
generate_skill,
|
generate_skill,
|
||||||
merge_registry,
|
merge_registry,
|
||||||
|
navigate,
|
||||||
parse_category_draft,
|
parse_category_draft,
|
||||||
parse_skill_draft,
|
parse_skill_draft,
|
||||||
)
|
)
|
||||||
|
from semif_agent.trace import TraceLog
|
||||||
|
|
||||||
|
|
||||||
def test_build_category_prompt_contains_request_and_tree():
|
def test_build_category_prompt_contains_request_and_tree():
|
||||||
@@ -177,4 +181,14 @@ def test_merge_registry_loads_categories_and_skills(tmp_path):
|
|||||||
merge_registry(tree, registry.read())
|
merge_registry(tree, registry.read())
|
||||||
names = [s.name for s in tree["delivery"]]
|
names = [s.name for s in tree["delivery"]]
|
||||||
assert names == ["track_live"]
|
assert names == ["track_live"]
|
||||||
assert tree["delivery"][0].category == "delivery"
|
assert tree["delivery"][0].category == "delivery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigate_empty_tree_short_circuits(tmp_path):
|
||||||
|
"""An empty tree goes straight to CreateCategory without a SemIf call."""
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
trace = TraceLog(str(tmp_path / "runs.jsonl"))
|
||||||
|
result = navigate(None, log, trace, Request("anything"), {})
|
||||||
|
assert isinstance(result, CreateCategory)
|
||||||
|
assert log.read() == []
|
||||||
|
assert any(e["kind"] == "create_category" for e in trace.read())
|
||||||
Reference in New Issue
Block a user