From 19bbba7ab7e6e6e8a2ea16fd8e10176b11d8ba3f Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Mon, 20 Jul 2026 18:55:17 +0200 Subject: [PATCH] add @dispatcher: rule-based routing worker (reply-only, no relay) Reads a request and names the right worker + the exact room-ask to run; never answers or forwards it, so a misroute costs one wasted hop. Keyword match first (no LLM slot burned), one LLM tie-break through the gated proxy only on ambiguity, full roster on weak signal. Strong debate markers route to @architect/@skeptic and win over an incidental worker-keyword hit. Roster manifest in the script is the single source of truth. Docs + skill updated with a start-here-if-unsure pointer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE --- README.md | 7 ++ bin/bullpen-dispatcher | 124 +++++++++++++++++++++++++++++ docs/invoking-from-a-rich-agent.md | 5 ++ skills/bullpen.md | 10 +++ systemd/bullpen-dispatcher.service | 12 +++ 5 files changed, 158 insertions(+) create mode 100755 bin/bullpen-dispatcher create mode 100644 systemd/bullpen-dispatcher.service diff --git a/README.md b/README.md index 13a4fd0..6303a6f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,13 @@ a human drops in through a terminal window. reactive worker (a *dumb dispatcher*, no LLM): the request body is a search query. Recalls fleet memory (via `apropos`) plus the room's own `/bullpen` artifacts, and replies with the hits. +- **`bin/bullpen-dispatcher`** + **`systemd/bullpen-dispatcher.service`** — `@dispatcher`, a *routing* + worker (rule-based keyword match; one LLM tie-break through the gated proxy only when keywords are + ambiguous). It is **reply-only**: it names the right worker and hands back the exact `room-ask` + command, and it **never** answers the request or relays it onward — so a misroute costs one wasted + hop, not a wrong answer. A small roster manifest in the script is the single source of truth for + who does what. + ## Adding a worker The reactive plumbing lives once in `lib/bullpen_worker.py` (deploy to diff --git a/bin/bullpen-dispatcher b/bin/bullpen-dispatcher new file mode 100755 index 0000000..6238efb --- /dev/null +++ b/bin/bullpen-dispatcher @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""@dispatcher — bullpen worker: routes a request to the right worker (reply-only, no relay). + +Rule-based keyword match first (fast, no LLM, no admission slot); an LLM tie-break +through the proxy only when the keywords are ambiguous; on weak/no signal it just +returns the full roster. It NEVER answers the request itself and NEVER forwards it — +it tells you who to ask and hands you the exact room-ask command to run. + + bullpen-dispatcher # room loop (systemd) + bullpen-dispatcher --once "fetch https://example.com" + bullpen-dispatcher --roster # print the roster manifest +""" +import json, re, sys, urllib.request +sys.path.insert(0, "/usr/local/lib/bullpen") +import bullpen_worker as bw + +# --- roster manifest: the single source of truth for who does what --- +# nick -> (one-liner, [trigger-keyword regexes]) +ROSTER = [ + ("callboy", "fetch/read ONE web page or URL (handles JS/Cloudflare/Anubis walls)", + [r"\bfetch\b", r"https?://", r"\burl\b", r"\bdownload\b", r"\bscrape\b", + r"\bweb ?page\b", r"\bwebsite\b", r"\bthis (?:link|page)\b"]), + ("researcher", "open web research: search + multiple sources + a CITED synthesis", + [r"\bresearch\b", r"\bcompare\b", r"\btrade-?off", r"\bcost vs\b", r"\bsources?\b", + r"\bcited?\b", r"\binvestigate\b", r"\bwhy (?:do|does|is|are)\b", + r"\bwhat is the best\b", r"\bevaluate\b"]), + ("coder", "write a Lua script from a task and run it sandboxed (returns code+output)", + [r"\bwrite\b.*\b(?:code|script|lua|function|parser?|program)\b", r"\blua\b", + r"\bscript\b", r"\bimplement\b", r"\bparse\b", r"\bfunction\b", + r"\balgorithm\b", r"\brun (?:it|this|the) (?:code|script)\b"]), + ("librarian", "recall the FLEET's own ops/kernel/infra memory + past fetched pages (no web)", + [r"\brecall\b", r"\bremember\b", r"\bmemory\b", r"\bfleet\b", r"\bwhat did we\b", + r"\bearlier\b", r"\bwhich host\b", r"\binfra(?:structure)?\b", r"\bnotes?\b", + r"\bwe (?:did|built|set up|configured)\b"]), +] +# conversants — turn-driven personas, NOT one-shot workers; for an open design debate. +# Only STRONG debate markers route here, and they take priority over an incidental +# worker-keyword hit ("debate the memory design" must not land on @librarian). +CONV_DESC = "turn-driven personas for a DESIGN DEBATE (not a one-shot answer)" +CONV_PATS = [r"\bdebate\b", r"\bargue\b", r"\bshould we\b", + r"\bpros? and cons?\b", r"\bdesign question\b"] + +ENGINE = "http://hossenfelder.fritz.box:8082/v1/chat/completions" +MODEL = "qwen3-4b-2507-npu" + + +def _show(body): + return body.replace('"', "'").replace("\n", " ").strip() + +def _score(body): + b = body.lower() + return sorted(((sum(1 for p in pats if re.search(p, b)), nick, desc) + for nick, desc, pats in ROSTER), reverse=True) + +def _route_line(nick, desc, asker, body): + return (f"→ @{nick} — {desc}.\n" + f" sic hertz room-ask --from {asker or ''} @{nick} \"{_show(body)}\"") + +def _roster_text(asker): + lines = ["not sure which one — here's who's on call:"] + for nick, desc, _ in ROSTER: + lines.append(f" @{nick} — {desc}") + lines.append(f" @architect / @skeptic — {CONV_DESC}") + lines.append(f"then: sic hertz room-ask --from {asker or ''} @ \"\"") + return "\n".join(lines) + +def _llm_pick(body): + """One cheap tie-break call through the gated proxy. Returns a nick or None.""" + names = [n for n, _, _ in ROSTER] + sys_p = ("You are a router. Pick the single best worker for the user's request. " + "Reply with ONLY one word — one of: " + ", ".join(names) + ", none.\n" + + "\n".join(f"{n}: {d}" for n, d, _ in ROSTER)) + payload = json.dumps({"model": MODEL, "max_tokens": 6, "temperature": 0, + "messages": [{"role": "system", "content": sys_p}, + {"role": "user", "content": body}]}).encode() + try: + req = urllib.request.Request(ENGINE, data=payload, + headers={"Content-Type": "application/json"}) + txt = json.loads(urllib.request.urlopen(req, timeout=45).read() + )["choices"][0]["message"]["content"].strip().lower() + for n, _, _ in ROSTER: + if re.search(rf"\b{n}\b", txt): + return n + except Exception: + pass + return None + +def route(body, asker=""): + body = (body or "").strip() + if not body: + return _roster_text(asker) + # a strong, explicit debate intent wins outright — even over an incidental + # worker-keyword hit (the intent to *discuss* beats a stray noun match) + if any(re.search(p, body.lower()) for p in CONV_PATS): + return (f"→ @architect / @skeptic — {CONV_DESC}.\n" + f" sic hertz lmcp-tool room_say from={asker or ''} to=@architect " + f"type=chat body=\"{_show(body)}\"") + scores = _score(body) + top_s, top_nick, top_desc = scores[0] + second_s = scores[1][0] + # a clear, unique keyword winner routes immediately (no LLM, no slot burned) + if top_s >= 1 and top_s > second_s: + return _route_line(top_nick, top_desc, asker, body) + # ambiguous (tie or no keyword hit): one LLM tie-break, else the full roster + pick = _llm_pick(body) + if pick: + desc = next(d for n, d, _ in ROSTER if n == pick) + return _route_line(pick, desc, asker, body) + return _roster_text(asker) + +def dispatch(msg): + return route(msg.get("body", ""), msg.get("from", "")) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--roster": + print(_roster_text("")) + elif len(sys.argv) > 2 and sys.argv[1] == "--once": + print(route(sys.argv[2], "you")) + else: + bw.run("dispatcher", dispatch, + online=("dispatcher online — tells you WHICH worker to ask " + "(routes, never answers). @dispatcher "), + ack="…routing") diff --git a/docs/invoking-from-a-rich-agent.md b/docs/invoking-from-a-rich-agent.md index b258ddc..8fbaf67 100644 --- a/docs/invoking-from-a-rich-agent.md +++ b/docs/invoking-from-a-rich-agent.md @@ -23,11 +23,16 @@ prints it, and exits. Flags: `--from NICK`, `--timeout N` (seconds). | Worker | Does | Notes | |---|---|---| +| `@dispatcher` | tells you *which* worker to ask | routes only, never answers; returns the exact `room-ask` to run. Ask it first when unsure | | `@callboy` | fetch/read a web page | handles JS/Cloudflare/Anubis walls; large pages return an abstract + `[full: sic hertz cat /var/lib/bullpen/artifacts/.txt · stash /bullpen]` handle | | `@librarian` | search fleet memory + past `@callboy` artifacts | fast, read-only, no LLM — says so honestly if nothing's relevant | | `@coder` | write + sandbox-run a Lua file from a task | returns code + output | | `@researcher` | web search + multi-source **cited** synthesis | slow, ~1–2 min | +Don't know the roster? `sic hertz room-ask --from @dispatcher ""` and it +hands you the right worker plus the command to run. Rule-based (fast); it never solves the task +itself, so a misroute costs one wasted hop, not a wrong answer. + Example: ``` sic hertz room-ask --from noether @callboy "fetch https://example.com/release-notes" diff --git a/skills/bullpen.md b/skills/bullpen.md index 4a963d4..549b501 100644 --- a/skills/bullpen.md +++ b/skills/bullpen.md @@ -14,6 +14,16 @@ sic hertz room-ask --from @callboy "fetch https://example.com" `` = your agent name (pica, pipi, noether, orca). It prints the worker's answer. +## Not sure who to ask? Ask @dispatcher +`@dispatcher` reads your request and tells you which worker to use, with the exact +`room-ask` command to run. It **routes only** — it never answers the request itself. + +``` +sic hertz room-ask --from @dispatcher "I need to pull a CVE list off a site and summarize it" +# → @callboy — fetch/read ONE web page or URL. +# sic hertz room-ask --from @callboy "..." +``` + ## Workers on call - **@callboy** — fetches/reads a web page (handles JS / Cloudflare / Anubis walls). `@callboy fetch ` or `@callboy summarize `. Large pages come back as a short diff --git a/systemd/bullpen-dispatcher.service b/systemd/bullpen-dispatcher.service new file mode 100644 index 0000000..197b71e --- /dev/null +++ b/systemd/bullpen-dispatcher.service @@ -0,0 +1,12 @@ +[Unit] +Description=bullpen @dispatcher — reactive routing worker (tells you which worker to ask) +After=network-online.target lmcp.service +Wants=lmcp.service + +[Service] +ExecStart=/usr/local/bin/bullpen-dispatcher +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target