cmpatino HF Staff commited on
Commit
12ea8e3
·
verified ·
1 Parent(s): 0a82323

PyBot: the story and a live chat that shows which files made each reply (pinned to 64eb2c84)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ site/fonts/LetterGothicCode-B.ttf filter=lfs diff=lfs merge=lfs -text
37
+ site/fonts/LetterGothicCode-R.ttf filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ RUN useradd -m -u 1000 user
3
+ USER user
4
+ WORKDIR /home/user/app
5
+ ENV PATH="/home/user/.local/bin:$PATH" PYTHONUNBUFFERED=1
6
+ COPY --chown=user requirements.txt ./
7
+ RUN pip install --no-cache-dir --user -r requirements.txt
8
+ COPY --chown=user app.py bot_runner.py ./
9
+ COPY --chown=user bot ./bot
10
+ COPY --chown=user site ./site
11
+ EXPOSE 7860
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,15 @@
1
  ---
2
  title: PyBot
3
- emoji: 👁
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
  title: PyBot
3
+ emoji: 🐍
4
+ colorFrom: gray
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ PyBot: a conversational bot without LLMs, written by a swarm of LLM agents. One page: the story and a live chat that shows
12
+ which of PyBot's modules and files produced each reply. Pinned to commit `64eb2c844738` of `HuggingFaceSwarms/SwarmChat-v8`.
13
+
14
+ `app.py` serves `site/` and `POST /api/chat` / `POST /api/trace`; `bot/bot-<sha12>.zip` holds the bot's source (`bot/**.py` only);
15
+ `bot_runner.py` runs it in a fresh worker per request. Built by `build_pybot.py` in the swarmchat-release workspace.
app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyBot release Space: the story page (site/) and the PyBot demo's chat endpoint.
2
+
3
+ The bot is one pinned commit's bot/**.py, shipped in bot/bot-<sha12>.zip, run by bot_runner (from SwarmChat-Demo):
4
+ one loader process, and a fresh forked worker per request that replays the conversation's user lines, so visitors
5
+ never share bot memory. Run only trusted bot code here and add no secrets: the worker is not a security sandbox.
6
+ """
7
+ import re
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import zipfile
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.staticfiles import StaticFiles
17
+ from pydantic import BaseModel, Field
18
+
19
+ from bot_runner import Bot, user_lines
20
+
21
+ HERE = Path(__file__).parent
22
+ TREE = tempfile.TemporaryDirectory(prefix="swarmchat-source-")
23
+ zipfile.ZipFile(next(HERE.glob("bot/bot-*.zip"))).extractall(TREE.name)
24
+ app = FastAPI(title="PyBot")
25
+ _bot, _bot_lock = None, threading.Lock()
26
+ _slots = threading.BoundedSemaphore(4) # conversations answered at once; cpu-basic has 2 vCPUs
27
+ _norm = lambda s: re.sub(r"[^a-z0-9]", "", s.lower())
28
+ # Every bot file, letters and digits only, to find which one holds a reply's text verbatim.
29
+ SOURCES = {p.relative_to(TREE.name).as_posix(): _norm(p.read_text(errors="replace")) for p in Path(TREE.name, "bot").rglob("*.py")}
30
+
31
+
32
+ def bot():
33
+ global _bot
34
+ with _bot_lock:
35
+ if _bot is None or not _bot.alive():
36
+ _bot = Bot(Path(TREE.name), seed=0, timeout=30, workers=2, log=sys.stderr)
37
+ return _bot
38
+
39
+
40
+ threading.Thread(target=bot, daemon=True).start() # load at boot, so the first visitor does not wait
41
+
42
+
43
+ class Message(BaseModel):
44
+ role: Literal["user", "assistant"]
45
+ content: str = Field(min_length=1, max_length=2000)
46
+
47
+
48
+ class ChatRequest(BaseModel):
49
+ messages: list[Message] = Field(min_length=1, max_length=60)
50
+
51
+
52
+ @app.post("/api/chat")
53
+ def chat(req: ChatRequest):
54
+ lines = user_lines([m.model_dump() for m in req.messages])
55
+ if not lines or req.messages[-1].role != "user":
56
+ raise HTTPException(400, "A user message is required.")
57
+ if sum(map(len, lines)) > 20000:
58
+ raise HTTPException(400, "This conversation is too long. Reload the page to start again.")
59
+ if not _slots.acquire(timeout=15):
60
+ raise HTTPException(503, "The bot is busy. Please try again in a moment.")
61
+ try:
62
+ result = bot().ask(lines)
63
+ finally:
64
+ _slots.release()
65
+ if result.get("status") != "ok" or len(result.get("replies", [])) != len(lines):
66
+ raise HTTPException(502, "The bot failed on that line. Try another one.")
67
+ return {"reply": result["replies"][-1]}
68
+
69
+
70
+ def answer_source(reply: str, used: list[str]) -> str | None:
71
+ """The one file that holds the reply verbatim (its first 40+ letters/digits), preferring files the winner called;
72
+ None when the reply is composed, too short, or found in several files."""
73
+ text = _norm(reply)
74
+ for n in (60, 40):
75
+ key = text[:n]
76
+ if len(key) < 20: # too short to point at one place
77
+ continue
78
+ hits = [f for f, body in SOURCES.items() if key in body]
79
+ hits = hits if len(hits) == 1 else [f for f in hits if f in used]
80
+ if len(hits) == 1:
81
+ return hits[0]
82
+ return None
83
+
84
+
85
+ @app.post("/api/trace")
86
+ def trace(req: ChatRequest):
87
+ """Like /api/chat, plus how the last reply was made: the module that won, the ones that also proposed a reply,
88
+ the bot files the winner called, and the file holding the reply's text."""
89
+ lines = user_lines([m.model_dump() for m in req.messages])
90
+ if not lines or req.messages[-1].role != "user":
91
+ raise HTTPException(400, "A user message is required.")
92
+ if sum(map(len, lines)) > 20000:
93
+ raise HTTPException(400, "This conversation is too long. Reload the page to start again.")
94
+ if not _slots.acquire(timeout=15):
95
+ raise HTTPException(503, "The bot is busy. Please try again in a moment.")
96
+ try:
97
+ result = bot().ask(lines, trace=True)
98
+ finally:
99
+ _slots.release()
100
+ if result.get("status") != "ok" or len(result.get("replies", [])) != len(lines) or not result.get("trace"):
101
+ raise HTTPException(502, "The bot failed on that line. Try another one.")
102
+ reply, info = result["replies"][-1], result["trace"]
103
+ return {"reply": reply, **info, "source": answer_source(reply, info["files"])}
104
+
105
+
106
+ @app.get("/health")
107
+ def health():
108
+ return {"ok": True}
109
+
110
+
111
+ app.mount("/", StaticFiles(directory=HERE / "site", html=True), name="site")
bot/bot-64eb2c844738.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e0650e67c8a4ab9935aac7f0f2f866c15daafed1e835d85ee4744d434a4a91d
3
+ size 9528378
bot_runner.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # From Space HuggingFaceSwarms/SwarmChat-Demo (bot_runner.py, itself adapted from flock's run4 eval server),
2
+ # plus a trace mode: which modules proposed a reply, which won, and the bot files the winner called.
3
+ """Serve a checkout of a swarm's chatbot as SwarmChat, an OpenAI-compatible endpoint for evals.
4
+
5
+ python experiments/run4/eval/swarmchat_server.py <chatbot checkout> --port 8000
6
+
7
+ Loading the bot takes seconds (hundreds of modules), answering takes milliseconds. So one
8
+ process loads the bot and forks a worker per conversation, and every conversation starts from
9
+ the same just-loaded state. The same lines get the same replies whatever the order or the
10
+ concurrency of the requests, in this run or the next: the hash seed, the global `random` state
11
+ (restored in each worker) and the seeds of the bot's own random.Random() instances are fixed.
12
+ Replies that read the clock or seed from an object id (a few modules use id(memory)) can still
13
+ change: ids move with address randomisation, which HF Jobs do not let us turn off.
14
+
15
+ POST /v1/chat/completions feeds the request's user messages to the bot in order (system and
16
+ assistant messages are ignored: the bot only reads user lines) and returns its last reply.
17
+ There is no sandbox: the bot runs as the server's user, with an empty environment, so run it
18
+ somewhere disposable such as an HF Job.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import signal
24
+ import select
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+
31
+ LOADER = r'''
32
+ import io, json, os, random, resource, runpy, signal, socket, sys, time
33
+ root, seed, timeout, path, workers = sys.argv[1], int(sys.argv[2]), float(sys.argv[3]), sys.argv[4], int(sys.argv[5])
34
+ sys.path.insert(0, root)
35
+ status = os.fdopen(os.dup(1), "w")
36
+ os.dup2(2, 1) # stray prints from the bot go to the log
37
+ listener = socket.socket(socket.AF_UNIX)
38
+ listener.bind(path)
39
+ listener.listen(256)
40
+ # A worker writes a byte here when it takes a conversation; the loader forks one per byte.
41
+ spawn_r, spawn_w = os.pipe()
42
+ os.write(spawn_w, bytes(workers))
43
+
44
+
45
+ def converse():
46
+ """A worker: take one connection, hold that conversation, send the result and exit."""
47
+ conn = listener.accept()[0]
48
+ os.write(spawn_w, b"\0")
49
+ random.setstate(loaded) # random reseeds itself from the OS in every forked child
50
+ started = time.monotonic()
51
+ request = json.loads(conn.makefile("rb").readline())
52
+ lines, trace = (request, False) if isinstance(request, list) else (request["lines"], bool(request.get("trace")))
53
+ limit = int(timeout) + 1
54
+ signal.alarm(limit)
55
+ resource.setrlimit(resource.RLIMIT_CPU, (limit, limit + 1))
56
+ if trace:
57
+ result, out = "ok", {}
58
+ try:
59
+ out = traced(lines)
60
+ except BaseException as exc:
61
+ result = f"error {type(exc).__name__}"
62
+ conn.sendall((json.dumps({"replies": out.get("replies", []), "trace": out.get("trace"), "status": result,
63
+ "seconds": round(time.monotonic() - started, 3)}) + "\n").encode())
64
+ os._exit(0)
65
+ sys.stdin = io.StringIO("".join(line + "\n" for line in lines))
66
+ sys.stdout = buffer = io.StringIO()
67
+ result = "ok"
68
+ try:
69
+ runpy.run_module("bot", run_name="__main__")
70
+ except BaseException as exc: # the fixed __main__ catches reply errors; this is the rest
71
+ result = f"error {type(exc).__name__}"
72
+ conn.sendall((json.dumps({"replies": buffer.getvalue().splitlines(), "status": result,
73
+ "seconds": round(time.monotonic() - started, 3)}) + "\n").encode())
74
+ os._exit(0)
75
+
76
+
77
+ def traced(lines):
78
+ """Replies like the fixed __main__ (one per line, whitespace-normalised, "Hello!" on errors), plus, for the last
79
+ line, the bot's own decision (bot.runtime.turn: candidates and winner) and the bot files each proposing handler called.
80
+ sys.monitoring records a function once and then disables that event, so tracing costs about nothing."""
81
+ import bot.runtime as rt
82
+ def say(text, fn):
83
+ try:
84
+ return " ".join(str(fn(text)).split()) or "Hello!"
85
+ except Exception: # noqa: BLE001 - the fixed __main__'s rule
86
+ return "Hello!"
87
+ memory = {}
88
+ replies = [say(line, lambda t: rt.reply(t, memory)) for line in lines[:-1]]
89
+ mon, tool, bot_dir, seen, files = sys.monitoring, 5, os.path.join(root, "bot") + os.sep, set(), {}
90
+ mon.use_tool_id(tool, "pybot-trace")
91
+ def start(code, offset):
92
+ if code.co_filename.startswith(bot_dir):
93
+ seen.add("bot/" + code.co_filename[len(bot_dir):])
94
+ return mon.DISABLE
95
+ mon.register_callback(tool, mon.events.PY_START, start)
96
+ def watch(name, respond):
97
+ def run(text, mem):
98
+ seen.clear(); mon.restart_events(); mon.set_events(tool, mon.events.PY_START)
99
+ try:
100
+ return respond(text, mem)
101
+ finally:
102
+ mon.set_events(tool, 0); files[name] = sorted(seen)
103
+ return run
104
+ rt._HANDLERS = [(name, priority, watch(name, respond)) for name, priority, respond in rt._HANDLERS]
105
+ decision = {}
106
+ def last(text):
107
+ decision.update(rt.turn(text, memory))
108
+ return decision["reply"]
109
+ replies.append(say(lines[-1], last))
110
+ module = decision.get("module")
111
+ return {"replies": replies, "trace": {"module": module,
112
+ "candidates": [c["module"] for c in decision.get("candidates", [])],
113
+ "files": files.get(module, []) if module else []}}
114
+
115
+
116
+ # Modules make their own random.Random(), which seeds itself from the OS: derive those seeds
117
+ # from the run's seed and a counter instead, so every run starts in the same state.
118
+ os_seeded, created = random.Random.seed, iter(range(1 << 62))
119
+ def derived_seed(self, a=None, version=2):
120
+ os_seeded(self, f"{seed}:{next(created)}" if a is None else a, version)
121
+ random.Random.seed = derived_seed
122
+ random.seed = random._inst.seed
123
+ random.seed(seed)
124
+ started = time.monotonic()
125
+ import bot.runtime
126
+ loaded = random.getstate()
127
+ print(json.dumps({"load_s": round(time.monotonic() - started, 2),
128
+ "handlers": len(bot.runtime._HANDLERS)}), file=status, flush=True)
129
+ # Keep `workers` forks waiting for a connection. The workers read the requests, never the
130
+ # loader, and the kernel reaps them.
131
+ signal.signal(signal.SIGCHLD, signal.SIG_IGN)
132
+ while True:
133
+ os.read(spawn_r, 1)
134
+ if os.fork() == 0:
135
+ converse()
136
+ '''
137
+
138
+
139
+ class Bot:
140
+ """The loader process; ask() holds one conversation in a fresh worker."""
141
+
142
+ def __init__(self, tree: Path, *, seed: int, timeout: float, workers: int, log):
143
+ self.timeout = timeout
144
+ self.tmp = tempfile.TemporaryDirectory(prefix="swarmchat-")
145
+ self.path = os.path.join(self.tmp.name, "bot.sock")
146
+ # No -I: PYTHONHASHSEED must apply. The environment holds nothing else.
147
+ self.proc = subprocess.Popen(
148
+ [sys.executable, "-s", "-S", "-P", "-c", LOADER, str(tree), str(seed), str(timeout),
149
+ self.path, str(workers)],
150
+ cwd=self.tmp.name, env={"PYTHONHASHSEED": str(seed)}, stdout=subprocess.PIPE,
151
+ stderr=log, text=True, start_new_session=True)
152
+ if not select.select([self.proc.stdout], [], [], 90)[0]:
153
+ self.close()
154
+ raise RuntimeError("The bot did not load within 90 seconds.")
155
+ line = self.proc.stdout.readline()
156
+ if not line:
157
+ self.close()
158
+ raise RuntimeError("This commit could not load its bot; try another commit.")
159
+ self.ready = json.loads(line)
160
+
161
+ def ask(self, lines: list[str], trace: bool = False) -> dict:
162
+ with socket.socket(socket.AF_UNIX) as conn:
163
+ conn.settimeout(self.timeout + 5)
164
+ conn.connect(self.path)
165
+ conn.sendall((json.dumps({"lines": lines, "trace": True} if trace else lines) + "\n").encode())
166
+ try:
167
+ raw = conn.makefile("rb").readline()
168
+ except TimeoutError:
169
+ return {"replies": [], "status": "timeout"}
170
+ # The worker died without replying, e.g. on its CPU or wall-clock limit.
171
+ return json.loads(raw) if raw else {"replies": [], "status": "worker exited"}
172
+
173
+ def alive(self) -> bool:
174
+ return self.proc.poll() is None
175
+
176
+ def close(self):
177
+ if self.alive():
178
+ os.killpg(self.proc.pid, signal.SIGKILL)
179
+ self.proc.wait()
180
+ self.proc.stdout.close()
181
+ self.tmp.cleanup()
182
+
183
+
184
+ def user_lines(messages) -> list[str]:
185
+ """User message texts as single bot input lines (the bot reads one line per turn)."""
186
+ lines = []
187
+ for m in messages:
188
+ if m.get("role") != "user":
189
+ continue
190
+ content = m.get("content") or ""
191
+ if isinstance(content, list):
192
+ content = " ".join(p.get("text", "") for p in content if p.get("type") == "text")
193
+ lines.append(" ".join(str(content).split()))
194
+ return lines
195
+
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn==0.32.1
site/fonts/LetterGothicCode-B.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d982d2025de5f980cbbf0e62b2d7036ea0e04a31019531cd995d2b3e378a8e2
3
+ size 112960
site/fonts/LetterGothicCode-R.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4af8b279313345424c02d7a2030826a831cc9fb367ff2251f09504d2b6f59ee6
3
+ size 110084
site/fonts/routed-gothic-narrow.ttf ADDED
Binary file (57.5 kB). View file
 
site/fonts/routed-gothic.ttf ADDED
Binary file (57.5 kB). View file
 
site/index.html ADDED
The diff for this file is too large to render. See raw diff