REPL when skill_create needs user input
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user