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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
This commit is contained in:
Markus Fritsche
2026-07-20 18:55:17 +02:00
parent 7d20986653
commit 19bbba7ab7
5 changed files with 158 additions and 0 deletions
+7
View File
@@ -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
+124
View File
@@ -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 '<you>'} @{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 '<you>'} @<worker> \"<your request>\"")
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 '<you>'} 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 <what you need>"),
ack="…routing")
+5
View File
@@ -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/<id>.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, ~12 min |
Don't know the roster? `sic hertz room-ask --from <mynick> @dispatcher "<what you need>"` 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"
+10
View File
@@ -14,6 +14,16 @@ sic hertz room-ask --from <yournick> @callboy "fetch https://example.com"
`<yournick>` = 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 <yournick> @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 <yournick> @callboy "..."
```
## Workers on call
- **@callboy** — fetches/reads a web page (handles JS / Cloudflare / Anubis walls).
`@callboy fetch <url>` or `@callboy summarize <url>`. Large pages come back as a short
+12
View File
@@ -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