From 4287eccc80db84549f3df9dcb7baf0090f946a7d Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 29 Jun 2026 22:42:54 +0200 Subject: [PATCH] feat(WARDEN-WP-0020): worker drafts real route answers in dry-run (T3 groundwork) build_plans now computes the concrete routing answer for each route_answer action in-process (reuses the catalog; read-only, no subprocess/network) and render_plans shows it as a `draft:` line. The dry-run demonstrates the actual answer the executor (T3) will send, not just an intent. RuleBrain stays the default; the llm-connect brain (T2) is gated on llm-connect being operational + its /execute contract. 230 tests, lint clean. Live dry-run verified against the real inbox. Co-Authored-By: Claude Opus 4.8 --- src/warden/worker.py | 40 ++++++++++++++++++++++++++++++++++++++-- tests/test_worker.py | 7 +++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/warden/worker.py b/src/warden/worker.py index 091b7c5..1d11630 100644 --- a/src/warden/worker.py +++ b/src/warden/worker.py @@ -149,9 +149,43 @@ class HubClient: return data if isinstance(data, list) else [] +def draft_route_answer(query: str) -> str: + """Compute the routing answer the worker would send for a query. Read-only. + + Reuses the routing catalog in-process (no subprocess, no network) so the dry-run + shows the concrete answer the executor (T3) will send, not just an intent. + """ + try: + from warden.routing.catalog import load_catalog + + matches = load_catalog().find(query, limit=1) + except Exception: # noqa: BLE001 — never let a lookup failure break planning + return "" + if not matches: + return f"No routing match for {query!r}; try `warden route list --all`." + e = matches[0] + role = "issue" if e.warden_executes else ("assist" if e.exec_capable else "route") + parts = [f"{e.id} — owner {e.owner_repo} ({e.subsystem}), warden role: {role}."] + if e.warden_executes and e.cert_command: + parts.append(f"Run: {e.cert_command}.") + elif e.has_native_exec: + parts.append(f"Primary: {e.exec_command}.") + elif e.exec_capable: + parts.append(f"Proxy: warden access {e.id} --fetch (as the caller).") + parts.append(f"See {e.wiki_ref}.") + return " ".join(parts) + + def build_plans(messages: List[dict], brain: Brain) -> List[WorkerPlan]: - """Plan every message and apply the guardrail pass. Pure — no execution.""" - return [_guardrail(brain.plan(m), m) for m in messages] + """Plan every message, attach computed route answers, and apply the guardrail pass.""" + plans: List[WorkerPlan] = [] + for m in messages: + plan = brain.plan(m) + for a in plan.actions: + if a.kind == "route_answer" and "answer" not in a.payload: + a.payload["answer"] = draft_route_answer(a.payload.get("query", m.get("subject", ""))) + plans.append(_guardrail(plan, m)) + return plans def render_plans(plans: List[WorkerPlan]) -> str: @@ -167,6 +201,8 @@ def render_plans(plans: List[WorkerPlan]) -> str: for a in p.actions: mark = "→" if a.risk == "safe" else "⚠" lines.append(f" {mark} {a.kind}: {a.summary}") + if a.payload.get("answer"): + lines.append(f" draft: {a.payload['answer']}") if a.risk == "escalate": lines.append(f" escalated: {a.reason}") return "\n".join(lines) diff --git a/tests/test_worker.py b/tests/test_worker.py index 91133b5..9aab4f5 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -92,6 +92,13 @@ def test_safe_reply_passes_guardrail(): # --- rendering --------------------------------------------------------------- +def test_build_plans_attaches_route_answer(): + # The npm question resolves against the real catalog → a concrete drafted answer. + [plan] = build_plans([_msg(subject="where do I get an npm token?")], RuleBrain()) + assert plan.actions and plan.actions[0].kind == "route_answer" + assert plan.actions[0].payload.get("answer") # non-empty computed answer + + def test_render_empty(): assert "inbox empty" in render_plans([])