diff --git a/semif_agent/scheduler.py b/semif_agent/scheduler.py index 3778690..f3e3086 100644 --- a/semif_agent/scheduler.py +++ b/semif_agent/scheduler.py @@ -152,8 +152,10 @@ class Scheduler: if self.current is None: weight, label = self._score(request) self.current = Process(request=request, skill="(scheduling)", weight=weight) - outcome = self._dispatch(request) - self.current = None + try: + outcome = self._dispatch(request) + finally: + self.current = None self.trace.append("ran", request.id, skill=outcome.skill, summary=outcome.summary) return "running", f"[{label}] {outcome.summary}" @@ -197,7 +199,11 @@ class Scheduler: outcome = self._dispatch(request) except EngineUnavailable as 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) results.append(("ran", f"[{request.id}] {outcome.summary}")) return results diff --git a/semif_agent/skills.py b/semif_agent/skills.py index 4d509c8..4e2a0e8 100644 --- a/semif_agent/skills.py +++ b/semif_agent/skills.py @@ -262,7 +262,10 @@ def navigate( 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. + 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()) 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?", 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) log.append(top, top_result, extra={"phase": "navigate:category", "run_id": request.id}) category = top_result.selected @@ -292,6 +306,18 @@ def navigate( question=f"Within {category}, which 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) log.append(leaf, leaf_result, extra={"phase": "navigate:leaf", "run_id": request.id}) pick = leaf_result.selected @@ -362,7 +388,7 @@ def generate_category( engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]] ) -> CategoryDraft: """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) @@ -413,5 +439,5 @@ 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)) + raw = engine.generate(build_skill_prompt(request, category, tree), max_tokens=128) return parse_skill_draft(raw) diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index 13a7774..c59fedd 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -137,4 +137,30 @@ def test_generate_skill(tmp_path): ) 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 + 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 \ No newline at end of file diff --git a/tests/test_skills.py b/tests/test_skills.py index cd24901..a8d6ab4 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -9,9 +9,11 @@ import pytest from semif_agent.decisions import Request from semif_agent.engine import EngineConfig, EngineUnavailable, SemIfEngine +from semif_agent.log import DecisionLog from semif_agent.skills import ( CategoryDraft, CategoryRegistry, + CreateCategory, SkillDraft, build_category_prompt, build_skill_prompt, @@ -20,9 +22,11 @@ from semif_agent.skills import ( generate_category, generate_skill, merge_registry, + navigate, parse_category_draft, parse_skill_draft, ) +from semif_agent.trace import TraceLog 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()) names = [s.name for s in tree["delivery"]] assert names == ["track_live"] - assert tree["delivery"][0].category == "delivery" \ No newline at end of file + 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()) \ No newline at end of file