REPL when skill_create needs user input

This commit is contained in:
Denton Social
2026-09-24 05:46:23 -05:00
parent e92b3e7228
commit 63922c1fad
15 changed files with 545 additions and 16 deletions
+61 -1
View File
@@ -17,7 +17,10 @@ from semif_agent.decisions import Request
from semif_agent.dream import dream
from semif_agent.engine import EngineUnavailable
from semif_agent.skills import (
ActionResult,
CategoryDraft,
Prediction,
Skill,
SkillBodyStore,
SkillDraft,
build_skills,
@@ -237,4 +240,61 @@ def test_create_category_chain_runs_new_skill(tmp_path):
created = next(e for e in rows if e["kind"] == "skill_created")
assert created["written"] is True, "codegen must produce a runnable body"
assessed = next(e for e in rows if e["kind"] == "assessed")
assert assessed["skill"] == created["skill"], "the created skill must run"
assert assessed["skill"] == created["skill"], "the created skill must run"
def test_skill_pauses_for_input_and_resumes(tmp_path):
"""A run paused for input keeps `current` busy, then `answer` resumes it.
Uses the real scheduler (real engine + real LLM assessment on the resumed
run). The skill itself is injected, not authored, so the flow is
deterministic: pause -> answer -> resume -> assessed.
"""
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)
seen = []
def predict(ctx, request):
return Prediction(text="", decisions=[])
def act(ctx, request, prediction):
if request.user_input:
seen.append(request.user_input)
return ActionResult(
action_log=f"resumed with {request.user_input}",
new_state=f"done {request.user_input}",
)
return ActionResult(
action_log="need a tracking number",
new_state=request.text,
needs_input="What's the tracking number?",
)
skill = Skill(
name="track.manual",
category="tracking",
description="Resolve a tracking number with the human.",
predict=predict,
act=act,
)
result = scheduler._run_skill(skill, Request("track my package manually"))
print(f"[{result.kind}] {result.summary}")
assert result.kind == "needs_input"
assert scheduler.pending is not None
assert scheduler.current is not None
status, detail = scheduler.answer("AB123")
print(f"[{status}] {detail}")
assert status == "ran"
assert seen == ["AB123"]
assert scheduler.pending is None
assert scheduler.current is None
rows = scheduler.trace.read()
kinds = [e["kind"] for e in rows]
assert "needs_input" in kinds and "answered" in kinds and "assessed" in kinds
+5 -1
View File
@@ -39,4 +39,8 @@ def test_request_requeue_preserves_state():
assert updated.id == original.id
assert updated.priority == original.priority
assert updated.resume["from_skill"] == "email.compose"
assert updated.reentries == original.reentries + 1
assert updated.reentries == original.reentries + 1
def test_request_user_input_defaults_none():
assert Request(text="t").user_input is None
+67 -1
View File
@@ -12,11 +12,12 @@ import urllib.request
from http.server import ThreadingHTTPServer
from semif_agent.dashboard import DashboardHandler
from semif_agent.decisions import DecisionRequest, DecisionResult, Option
from semif_agent.decisions import DecisionRequest, DecisionResult, Option, Request
from semif_agent.engine import EngineConfig, SemIfEngine
from semif_agent.llm import LLMClient
from semif_agent.log import DecisionLog
from semif_agent.scheduler import Scheduler
from semif_agent.skills import ActionResult, Prediction, Skill
from semif_agent.trace import TraceLog
@@ -182,5 +183,70 @@ def test_skill_writing_and_created_events_in_payload(tmp_path):
created = next(e for e in run["events"] if e["kind"] == "skill_created")
assert created["written"] is True
assert created["body"] == "data/skills/tracking/track_live.py"
finally:
server.close()
def need_input_skill(seen):
def predict(ctx, request):
return Prediction(text="", decisions=[])
def act(ctx, request, prediction):
if request.user_input:
seen.append(request.user_input)
return ActionResult(action_log="ok", new_state=f"done {request.user_input}")
return ActionResult(
action_log="ask",
new_state=request.text,
needs_input="What's the tracking number?",
)
return Skill(
name="track.manual",
category="tracking",
description="Resolve a tracking number with the human.",
predict=predict,
act=act,
)
def test_answer_without_pending_returns_error_json(tmp_path):
scheduler = build_scheduler(tmp_path)
server = Server(scheduler)
try:
status, payload = server.post("/api/answer", {"text": "hello"})
assert status == 200
assert payload["status"] == "error"
assert "waiting for input" in payload["detail"]
finally:
server.close()
def test_status_includes_pending(tmp_path):
scheduler = build_scheduler(tmp_path)
scheduler._run_skill(need_input_skill([]), Request("track my package"))
server = Server(scheduler)
try:
status, payload = server.get("/api/status")
assert status == 200
assert payload["pending"]["skill"] == "track.manual"
assert payload["pending"]["question"] == "What's the tracking number?"
finally:
server.close()
def test_answer_roundtrip_via_api(tmp_path):
scheduler = build_scheduler(tmp_path)
seen = []
scheduler._run_skill(need_input_skill(seen), Request("track my package"))
server = Server(scheduler)
try:
status, payload = server.post("/api/answer", {"text": "AB123"})
assert status == 200
assert payload["status"] == "ran"
assert seen == ["AB123"]
status, payload = server.get("/api/status")
assert payload["pending"] is None
finally:
server.close()
+177
View File
@@ -0,0 +1,177 @@
"""Pure-stdlib tests for the runtime user-input channel.
A skill can pause its run by returning ActionResult(..., needs_input="<q>");
the scheduler keeps the run pending, and `answer` resumes it by re-invoking
only `act` with the human's answer on request.user_input. No mocking: the
scheduler uses the lazy engine (never loaded) and a real-but-unreachable LLM
endpoint, so assessments degrade to failure — which is fine for these tests.
"""
from semif_agent.decisions import DecisionRequest, DecisionResult, Option, Request
from semif_agent.engine import EngineConfig, SemIfEngine
from semif_agent.llm import LLMClient
from semif_agent.log import DecisionLog
from semif_agent.scheduler import Scheduler
from semif_agent.skills import ActionResult, Prediction, Skill
from semif_agent.trace import TraceLog
def build_scheduler(tmp_path):
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
trace = TraceLog(str(tmp_path / "runs.jsonl"))
engine = SemIfEngine(EngineConfig())
llm = LLMClient(base_url="http://localhost:1/v1", model="test")
return Scheduler(
engine=engine,
llm=llm,
log=log,
config={"skills": {}},
trace=trace,
)
def need_input_skill(seen):
def predict(ctx, request):
return Prediction(text="", decisions=[])
def act(ctx, request, prediction):
if request.user_input:
seen.append(request.user_input)
return ActionResult(
action_log=f"got {request.user_input}", new_state=f"done {request.user_input}"
)
return ActionResult(
action_log="need a tracking number",
new_state=request.text,
needs_input="What's the tracking number?",
)
return Skill(
name="track.manual",
category="tracking",
description="Resolve a tracking number with the human.",
predict=predict,
act=act,
)
def test_skill_pause_and_answer(tmp_path):
scheduler = build_scheduler(tmp_path)
seen = []
request = Request("track my package manually")
result = scheduler._run_skill(need_input_skill(seen), request)
assert result.kind == "needs_input"
assert result.needs_input == "What's the tracking number?"
assert scheduler.pending is not None
assert scheduler.pending.question == "What's the tracking number?"
assert scheduler.current is not None
assert scheduler.current.skill == "track.manual"
assert scheduler.log.read() == [], "predict decisions must be deferred until completion"
status, detail = scheduler.answer("AB123")
assert status == "ran"
assert seen == ["AB123"]
assert scheduler.pending is None
assert scheduler.current is None
kinds = [e["kind"] for e in scheduler.trace.read()]
assert "needs_input" in kinds
assert "answered" in kinds
assert "assessed" in kinds
assert "ran" in kinds
def test_answer_without_pending_is_error(tmp_path):
scheduler = build_scheduler(tmp_path)
status, detail = scheduler.answer("hello")
assert status == "error"
assert "waiting for input" in detail
def test_predict_decisions_logged_on_completion(tmp_path):
decision = DecisionRequest(
state="s", question="which?", options=[Option("a", "A."), Option("b", "B.")]
)
result = DecisionResult(
request=decision, option_ids=["a", "b"], probabilities=[0.3, 0.7]
)
def predict(ctx, request):
return Prediction(text="", decisions=[(decision, result)])
def act(ctx, request, prediction):
if request.user_input:
return ActionResult(action_log="ok", new_state="done")
return ActionResult(
action_log="ask", new_state=request.text, needs_input="confirm?"
)
skill = Skill(name="t.x", category="t", description="", predict=predict, act=act)
scheduler = build_scheduler(tmp_path)
scheduler._run_skill(skill, Request("x"))
assert scheduler.log.read() == []
scheduler.answer("yes")
rows = scheduler.log.read()
assert len(rows) == 1
assert rows[0]["extra"]["phase"] == "predict"
assert rows[0]["extra"]["run_ok"] is False
def test_busy_abandons_pending(tmp_path):
scheduler = build_scheduler(tmp_path)
request = Request("track")
scheduler._run_skill(need_input_skill([]), request)
assert scheduler.pending is not None
scheduler.busy("driving on the freeway", skill="driving")
assert scheduler.pending is None
assert any(e["kind"] == "pending_abandoned" for e in scheduler.trace.read())
def test_idle_abandons_pending(tmp_path):
scheduler = build_scheduler(tmp_path)
scheduler._run_skill(need_input_skill([]), Request("track"))
assert scheduler.pending is not None
scheduler.idle()
assert scheduler.pending is None
assert scheduler.current is None
def test_resume_can_ask_again(tmp_path):
seen = []
def predict(ctx, request):
return Prediction(text="", decisions=[])
def act(ctx, request, prediction):
if request.user_input == "AB123":
return ActionResult(action_log="done", new_state="resolved")
if request.user_input:
seen.append(request.user_input)
return ActionResult(
action_log="wrong format",
new_state=request.text,
needs_input="That wasn't a valid tracking number. Try again?",
)
return ActionResult(
action_log="ask", new_state=request.text, needs_input="Tracking number?"
)
skill = Skill(name="t.x", category="t", description="", predict=predict, act=act)
scheduler = build_scheduler(tmp_path)
scheduler._run_skill(skill, Request("track"))
status, detail = scheduler.answer("XYZ")
assert status == "needs_input"
assert seen == ["XYZ"]
assert scheduler.pending is not None
status, detail = scheduler.answer("AB123")
assert status == "ran"
assert scheduler.pending is None
assert scheduler.current is None
+5
View File
@@ -13,6 +13,7 @@ from semif_agent.llm import LLMClient
from semif_agent.log import DecisionLog
from semif_agent.scheduler import Scheduler
from semif_agent.skills import (
ActionResult,
CategoryDraft,
CategoryRegistry,
CreateCategory,
@@ -31,6 +32,10 @@ from semif_agent.skills import (
from semif_agent.trace import TraceLog
def test_action_result_needs_input_defaults_none():
assert ActionResult("log", "state").needs_input is None
def test_build_category_prompt_contains_request_and_tree():
tree = build_tree(build_skills({"skills": {}}))
messages = build_category_prompt(Request("tracking for my drone delivery"), tree)