diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 780460a..11440da 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -53,6 +53,12 @@ def _parse_skill(path: Path) -> Skill: "log", or "capture", write a file there. No git operations — Syncthing handles sync. +You have a hard wall-clock budget of {timeout_s} seconds for this +entire request. If your process exceeds it, it is killed and the user gets +no result. Scope your work to fit: prefer a smaller, complete action over an +ambitious one that risks timing out. If the request is too big to finish in +time, do the most valuable part you can and say so in the JSON summary. + {skill_section} Skills are suggestions, not constraints. Use as many as the command needs, @@ -72,8 +78,12 @@ def _parse_skill(path: Path) -> Skill: """ -def build_system_prompt(skills: list[Skill]) -> str: - """Build the system prompt sent to the Claude CLI for a Pebble request.""" +def build_system_prompt(skills: list[Skill], timeout_s: int = 300) -> str: + """Build the system prompt sent to the Claude CLI for a Pebble request. + + ``timeout_s`` is the hard wall-clock budget enforced by the runner; it is + surfaced in the prompt so Claude can scope work to fit. + """ if not skills: skill_section = "Available skills: (none currently registered)" else: @@ -85,7 +95,9 @@ def build_system_prompt(skills: list[Skill]) -> str: "Skill file paths:\n\n" f"{files}" ) - return _SYSTEM_PROMPT_TEMPLATE.format(skill_section=skill_section) + return _SYSTEM_PROMPT_TEMPLATE.format( + skill_section=skill_section, timeout_s=timeout_s, + ) def build_user_prompt(text: str, timestamp: int) -> str: diff --git a/src/clayde/webhook/worker.py b/src/clayde/webhook/worker.py index d9e79e6..0755c1b 100644 --- a/src/clayde/webhook/worker.py +++ b/src/clayde/webhook/worker.py @@ -56,7 +56,7 @@ async def process_job(job: PebbleJob, *, timeout_s: int, kb_path: str) -> None: skills = discover_skills(SKILLS_ROOT) span.set_attribute("pebble.skills_available", len(skills)) - system_prompt = build_system_prompt(skills) + system_prompt = build_system_prompt(skills, timeout_s=timeout_s) user_text = build_user_prompt(job.text, job.timestamp) t0 = time.monotonic() diff --git a/tests/test_pebble_e2e.py b/tests/test_pebble_e2e.py index eceb906..e94af0b 100644 --- a/tests/test_pebble_e2e.py +++ b/tests/test_pebble_e2e.py @@ -40,7 +40,7 @@ async def fake_invoke(**kwargs): monkeypatch.setattr(worker_mod, "invoke_claude_pebble", fake_invoke) monkeypatch.setattr(worker_mod, "discover_skills", lambda root=None: []) - monkeypatch.setattr(worker_mod, "build_system_prompt", lambda skills: "SYS") + monkeypatch.setattr(worker_mod, "build_system_prompt", lambda skills, timeout_s=300: "SYS") monkeypatch.setattr(worker_mod, "build_user_prompt", lambda text, ts: text) # Real queue + real worker_loop. diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index a4e3cef..14a9c40 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -120,6 +120,14 @@ def test_build_system_prompt_empty_catalog(): assert "(none currently registered)" in prompt +def test_prompt_mentions_timeout_budget(): + p = build_system_prompt([], timeout_s=300) + assert "300 seconds" in p + assert "budget" in p.lower() + # the runner kills on overrun — the agent must be told to scope work + assert "killed" in p.lower() + + def test_build_user_prompt(): out = build_user_prompt("hello world", 1778068506) assert "1778068506" in out diff --git a/tests/test_webhook_worker.py b/tests/test_webhook_worker.py index d4f6ec0..813be91 100644 --- a/tests/test_webhook_worker.py +++ b/tests/test_webhook_worker.py @@ -36,7 +36,7 @@ async def fake_send(*, title, body, success, **_): @pytest.fixture def fake_skills(monkeypatch): monkeypatch.setattr(worker, "discover_skills", lambda root=None: []) - monkeypatch.setattr(worker, "build_system_prompt", lambda skills: "SYS") + monkeypatch.setattr(worker, "build_system_prompt", lambda skills, timeout_s=300: "SYS") monkeypatch.setattr(worker, "build_user_prompt", lambda text, ts: f"USER:{text}")