diff --git a/AGENTS.md b/AGENTS.md index f2d3896..1f74b01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,8 +209,11 @@ unit tests (24) + box integration tests (2). body…" badge) → sync codegen write → `materialize_skill` → hot-merge into the tree → the new leaf runs directly so the request is answered. `create_category` runs the same chain after authoring the category (`create_category` → - `create_skill` → run). Codegen failure leaves a navigable stub and returns a - graceful `create_skill` result. + `create_skill` → run). Codegen failure — including a request timeout — leaves + a navigable stub and returns a graceful `create_skill` result; a timeout is + raised as `CodegenError` by the client, never a raw `TimeoutError`. The + default codegen timeout is 1200s (`cli.build_scheduler`); raise + `codegen.timeout` in config for harder prompts. ### Code principles - **No mocking.** The decision engine is always real SemIf; the LLM is always a diff --git a/semif_agent/cli.py b/semif_agent/cli.py index 157e4fb..88c5412 100644 --- a/semif_agent/cli.py +++ b/semif_agent/cli.py @@ -52,7 +52,7 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]: "base_url", config.get("llm", {}).get("base_url", "http://localhost:11434/v1") ), model=codegen_cfg.get("model", "qwen38-iq3s"), - timeout=float(codegen_cfg.get("timeout", 600.0)), + timeout=float(codegen_cfg.get("timeout", 1200.0)), ) log = DecisionLog(config.get("log", "data/decisions.jsonl")) trace = TraceLog(config.get("trace", "data/runs.jsonl")) diff --git a/semif_agent/codegen.py b/semif_agent/codegen.py index 97415ac..fb8eaa0 100644 --- a/semif_agent/codegen.py +++ b/semif_agent/codegen.py @@ -72,6 +72,10 @@ class CodegenClient: raise CodegenError( f"codegen endpoint unreachable at {url}: {exc}. Is your local server running?" ) from exc + except TimeoutError as exc: + raise CodegenError( + f"codegen request timed out after {self.timeout}s at {url}" + ) from exc return payload["choices"][0]["message"]["content"] diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py index b59c4a6..10b2e00 100644 --- a/tests/integration/test_pipeline.py +++ b/tests/integration/test_pipeline.py @@ -162,7 +162,7 @@ def test_generate_skill_body_codegen(tmp_path): client = CodegenClient( base_url=codegen_cfg.get("base_url", "http://localhost:11434/v1"), model=codegen_cfg.get("model", "qwen38-iq3s"), - timeout=float(codegen_cfg.get("timeout", 600.0)), + timeout=float(codegen_cfg.get("timeout", 1200.0)), ) tree = build_tree(build_skills({"skills": {}})) draft = SkillDraft( @@ -196,6 +196,7 @@ def test_create_skill_empty_category_does_not_wedge(tmp_path): config["log"] = str(tmp_path / "decisions.jsonl") config["trace"] = str(tmp_path / "runs.jsonl") scheduler, config = build_scheduler(config) + scheduler.codegen = None # wedge regression only; skip the ~7min codegen body write scheduler.tree["travel_planning"] = [] for _ in range(2): diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 9b9f39d..2268b46 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -8,12 +8,14 @@ the CodegenClient itself is real, not mocked. import json import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest from semif_agent.codegen import ( CodegenClient, + CodegenError, build_skill_body_prompt, generate_skill_body, parse_skill_body, @@ -217,10 +219,39 @@ def test_codegen_client_unreachable_raises(tmp_path): client = CodegenClient(base_url="http://127.0.0.1:1/v1", model="test", timeout=2) tree = build_tree(build_skills({"skills": {}})) draft = SkillDraft(name="probe", description="Probe the service.") - with pytest.raises(Exception): + with pytest.raises(CodegenError): generate_skill_body(client, Request("is the service up?"), "tracking", draft, tree) +class _SilentOpenAI(BaseHTTPRequestHandler): + """Accepts the request but never replies; the client must time out.""" + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + self.rfile.read(length) + time.sleep(5) + + def log_message(self, format, *args): + pass + + +def test_chat_timeout_raises_codegen_error(): + handler = type("Handler", (_SilentOpenAI,), {}) + httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + client = CodegenClient( + base_url=f"http://127.0.0.1:{httpd.server_address[1]}/v1", + model="test", + timeout=0.5, + ) + with pytest.raises(CodegenError, match="timed out"): + client.chat([{"role": "user", "content": "hi"}]) + finally: + httpd.shutdown() + httpd.server_close() + + def test_chat_omits_max_tokens_by_default(): httpd, base = _fake_server(GOOD_BODY) try: