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:
@@ -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
|
||||
|
||||
Executable
+124
@@ -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")
|
||||
@@ -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, ~1–2 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user