passiveworkers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
council/local.py ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/local.py — single-player deep research, one command (D16)
4
+ ==================================================================
5
+ python -m council.local "your brief" # or: pw research "your brief"
6
+ python -m council.local "brief" --quick|--deep
7
+ python -m council.local "brief" --editor api # BYOK frontier editor over LOCAL findings
8
+
9
+ Everything runs on THIS machine: it plans queries, researches the live web from YOUR
10
+ connection, runs multiple installed Ollama models as independent analysts, and the
11
+ strongest model (the blind editor) compiles a cited markdown report saved to ./reports/.
12
+ Private by construction: no account, no server, no telemetry — only the web searches
13
+ leave the machine. Models hold zero tool privileges (they return text; Python acts).
14
+
15
+ The network (council/net/) is the optional multiplayer mode — this command needs none of it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import datetime
22
+ import os
23
+ import pathlib
24
+ import re
25
+ import sys
26
+ import time
27
+
28
+ import requests
29
+
30
+ from council.judge import Judge, _extract_json
31
+ from council.researcher import ResearchWorker
32
+ from council.sanitize import sanitize_brief
33
+ from council.worker import Answer
34
+
35
+ OLLAMA = os.environ.get("PW_OLLAMA_BASE", "http://localhost:11434")
36
+ # families give genuinely different inference trajectories — pick analysts across them
37
+ _FAMILIES = ("qwen", "gemma", "llama", "mistral", "phi", "deepseek", "granite")
38
+ _EXCLUDE = ("embed", "vision", "image")
39
+
40
+
41
+ def _make_emit(on_progress):
42
+ def emit(msg: str) -> None:
43
+ # stderr, not stdout: keeps the MCP stdio JSON-RPC channel clean (still shown in terminals)
44
+ print(f" {msg}", file=sys.stderr, flush=True)
45
+ if on_progress:
46
+ try:
47
+ on_progress(msg)
48
+ except Exception:
49
+ pass
50
+ return emit
51
+
52
+
53
+ def detect_models() -> list[dict]:
54
+ r = requests.get(f"{OLLAMA}/api/tags", timeout=10)
55
+ r.raise_for_status()
56
+ models = [m for m in r.json().get("models", [])
57
+ if not any(x in m["name"].lower() for x in _EXCLUDE)]
58
+ # On CPU-only / constrained machines big models crawl (3-6 tok/s) — let users cap
59
+ # the cast by size: PW_MODEL_CAP_GB=3 keeps only models whose weights fit that budget.
60
+ cap = float(os.environ.get("PW_MODEL_CAP_GB", "0") or 0)
61
+ if cap > 0:
62
+ capped = [m for m in models if m.get("size", 0) <= cap * 1e9]
63
+ models = capped or models[:1] # never end up with zero models
64
+ if not models:
65
+ raise SystemExit("No usable Ollama models found. Try: ollama pull qwen3:14b")
66
+ return sorted(models, key=lambda m: m.get("size", 0))
67
+
68
+
69
+ def pick_cast(models: list[dict], n_analysts: int = 3) -> tuple[list[str], str]:
70
+ """Analysts = up to N models across different families (diversity reduces correlated
71
+ error); editor/judge = the largest model installed (the quality anchor)."""
72
+ editor = models[-1]["name"]
73
+ by_family: dict[str, str] = {}
74
+ for m in reversed(models): # biggest of each family first
75
+ fam = next((f for f in _FAMILIES if f in m["name"].lower()), m["name"].split(":")[0])
76
+ by_family.setdefault(fam, m["name"])
77
+ analysts = list(by_family.values())[:n_analysts]
78
+ if not analysts:
79
+ analysts = [editor]
80
+ return analysts, editor
81
+
82
+
83
+ class _ApiEditor(Judge):
84
+ """BYOK Tier-3: the same editor prompts, generated by a frontier model the USER chose.
85
+ Only locally-gathered findings are sent — never raw pages, never local files."""
86
+
87
+ def __init__(self, model: str, api_key: str, url: str):
88
+ super().__init__(model=model)
89
+ self._key, self._url = api_key, url
90
+
91
+ def _generate(self, prompt: str, num_predict=None) -> str:
92
+ r = requests.post(self._url, headers={"Authorization": f"Bearer {self._key}"},
93
+ json={"model": self.model,
94
+ "messages": [{"role": "user", "content": prompt}],
95
+ "max_tokens": num_predict or 900}, timeout=120)
96
+ r.raise_for_status()
97
+ return (r.json()["choices"][0]["message"]["content"] or "").strip()
98
+
99
+
100
+ def plan_angles(brief: str, planner_model: str, k: int) -> list[str]:
101
+ """STORM-lite: one cheap call on the SMALLEST model discovers K distinct perspectives;
102
+ each analyst then researches the brief through its own angle (question diversity on
103
+ top of model diversity). Empty list on failure — analysts research angle-less."""
104
+ if k < 2:
105
+ return []
106
+ try:
107
+ r = requests.post(f"{OLLAMA}/api/generate", json={
108
+ "model": planner_model,
109
+ "prompt": ("Identify exactly "
110
+ f"{k} DISTINCT angles for researching this brief — different lenses "
111
+ "that together cover it (e.g. regulatory, costs/economics, "
112
+ "practitioner experience, recent developments — pick what fits THIS "
113
+ 'brief). Reply STRICT JSON only: ["angle one","angle two",…]\n\n'
114
+ f"BRIEF:\n{brief}\n\nJSON:"),
115
+ "stream": False, "options": {"temperature": 0.3, "num_predict": 160},
116
+ "keep_alive": os.environ.get("PW_OLLAMA_KEEP_ALIVE", "30m")}, # warm the planner (R17)
117
+ timeout=float(os.environ.get("PW_OLLAMA_TIMEOUT", "300")))
118
+ r.raise_for_status()
119
+ parsed = _extract_json((r.json().get("response") or "").strip())
120
+ angles = [str(a).strip() for a in parsed if str(a).strip()] if isinstance(parsed, list) else []
121
+ return angles[:k]
122
+ except Exception:
123
+ return []
124
+
125
+
126
+ def fix_dangling_citations(text: str) -> str:
127
+ """Drop [S#]/[L#] markers that don't resolve to a listed source (web [S#] or local
128
+ document [L#]); the source lists are appended inline so the check is local to the block."""
129
+ listed = set(re.findall(r"^\[([SL]\d+)\]", text, re.MULTILINE))
130
+ if not listed:
131
+ return text
132
+ return re.sub(r"\[([SL]\d+)(?:,\s*(?:[SL]\d+\s*)+)?\]",
133
+ lambda m: m.group(0) if m.group(1) in listed else "", text)
134
+
135
+
136
+ def run(brief: str, depth: str = "standard", editor_mode: str = "local",
137
+ out_dir: str = "reports", n_analysts: int = 3, scope: str = "both",
138
+ on_progress=None) -> pathlib.Path:
139
+ t0 = time.monotonic()
140
+ emit = _make_emit(on_progress)
141
+ brief = sanitize_brief(brief) # the one user input → clean + length-bound
142
+ if not brief:
143
+ raise ValueError("empty brief — give something to research")
144
+ os.environ.setdefault("PW_WEB_BACKEND", "ddgs") # live web ON — the whole point
145
+
146
+ models = detect_models()
147
+ analysts, editor_model = pick_cast(models, n_analysts)
148
+ emit(f"🔬 Deep research ({depth}) — analysts: {', '.join(analysts)} · editor: "
149
+ f"{editor_model if editor_mode == 'local' else editor_mode}")
150
+
151
+ # STORM-lite perspective planning: distinct angle per analyst (smallest model plans)
152
+ angles = plan_angles(brief, models[0]["name"], len(analysts))
153
+ if angles:
154
+ emit("angles: " + " | ".join(angles))
155
+ page_evidence = not float(os.environ.get("PW_MODEL_CAP_GB", "0") or 0) # CPU-capped → snippets
156
+
157
+ contributions, answers = [], []
158
+ for i, model in enumerate(analysts, 1):
159
+ angle = angles[i - 1] if i <= len(angles) else ""
160
+ emit(f"[{i}/{len(analysts)}] {model} researching the live web…"
161
+ + (f" (angle: {angle})" if angle else ""))
162
+ t = time.monotonic()
163
+ rw = ResearchWorker(worker_id=model, model=model, lens="independent analyst",
164
+ country=os.environ.get("PW_COUNTRY", "your location"),
165
+ depth=depth, angle=angle, page_evidence=page_evidence, scope=scope)
166
+ out = rw.research(brief)
167
+ text = fix_dangling_citations(out["text"])
168
+ nsrc = len(out["research"]["sources"])
169
+ emit(f" {nsrc} sources · {len(text.split())} words · {time.monotonic()-t:.0f}s")
170
+ contributions.append({"country": model.split(":")[0], "model": model,
171
+ "lens": "analyst", "text": text,
172
+ "research": out["research"]})
173
+ answers.append(Answer(worker_id=model, model=model, lens="analyst",
174
+ country=model.split(":")[0], text=text, tokens=out["tokens"],
175
+ elapsed_s=out["elapsed_s"]))
176
+
177
+ emit("blind judge + editor compiling the report…")
178
+ if editor_mode == "local":
179
+ judge = Judge(model=editor_model)
180
+ else:
181
+ key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("PW_BASELINE_API_KEY")
182
+ if not key:
183
+ raise SystemExit("--editor api needs OPENROUTER_API_KEY (or PW_BASELINE_API_KEY)")
184
+ judge = _ApiEditor(os.environ.get("PW_EDITOR_MODEL", "openai/gpt-5-chat"), key,
185
+ os.environ.get("PW_BASELINE_API_URL",
186
+ "https://openrouter.ai/api/v1/chat/completions"))
187
+ read = judge.deliberate(brief, answers)
188
+ report = judge.compile_report(brief, contributions, read, local=True)
189
+
190
+ out_path = pathlib.Path(out_dir)
191
+ out_path.mkdir(exist_ok=True)
192
+ slug = re.sub(r"[^a-z0-9]+", "-", brief.lower())[:60].strip("-")
193
+ fname = out_path / f"{datetime.date.today().isoformat()}-{slug}.md"
194
+ n = 1
195
+ while fname.exists():
196
+ n += 1
197
+ fname = out_path / f"{datetime.date.today().isoformat()}-{slug}-{n}.md"
198
+ fname.write_text(report)
199
+ mins = (time.monotonic() - t0) / 60
200
+ total_src = sum(len(c["research"]["sources"]) for c in contributions)
201
+ emit(f"📄 Report ready in {mins:.1f} min · {len(report.split())} words · "
202
+ f"{total_src} sources → {fname}")
203
+ return fname
204
+
205
+
206
+ def main() -> int:
207
+ p = argparse.ArgumentParser(prog="pw research",
208
+ description="Local-first deep research with your own models.")
209
+ p.add_argument("brief", help="what should be researched")
210
+ p.add_argument("--quick", action="store_true", help="1 research round (fastest)")
211
+ p.add_argument("--deep", action="store_true", help="extra research rounds")
212
+ p.add_argument("--editor", choices=["local", "api"], default="local",
213
+ help="api = BYOK frontier editor over local findings (OPENROUTER_API_KEY)")
214
+ p.add_argument("--analysts", type=int, default=3, help="how many local models analyze (1-4)")
215
+ p.add_argument("--out", default="reports", help="output directory")
216
+ g = p.add_mutually_exclusive_group()
217
+ g.add_argument("--local", action="store_true", help="research ONLY your library (no web)")
218
+ g.add_argument("--web", action="store_true", help="research ONLY the live web (no library)")
219
+ a = p.parse_args()
220
+ depth = "quick" if a.quick else "deep" if a.deep else "standard"
221
+ scope = "local" if a.local else "web" if a.web else "both"
222
+ run(a.brief, depth=depth, editor_mode=a.editor, out_dir=a.out,
223
+ n_analysts=max(1, min(4, a.analysts)), scope=scope)
224
+ return 0
225
+
226
+
227
+ if __name__ == "__main__":
228
+ sys.exit(main())
council/mcp_server.py ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/mcp_server.py — Passive Workers as an MCP tool (D19)
4
+ ============================================================
5
+ Exposes the local research engine over the Model Context Protocol (stdio), so your OWN
6
+ agentic AI — Claude Desktop, Codex, any MCP client — can call it as a tool. This is the
7
+ project's interop play and the founder's worldview made real: the human's assistant
8
+ orchestrates; our multi-model, live-web + private-library research engine is the capability
9
+ it reaches for. Everything still runs locally; nothing leaves the machine but web searches.
10
+
11
+ Run: pw mcp (or: python -m council.mcp_server)
12
+
13
+ Claude Desktop config (claude_desktop_config.json):
14
+ {"mcpServers": {"passive-workers": {"command": "pw", "args": ["mcp"]}}}
15
+
16
+ Tools:
17
+ research(brief, depth="quick", analysts=2, scope="both") -> cited markdown report
18
+ library_search(query, k=5) -> your private-document hits
19
+ library_add(path) -> index a file/dir into the library
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+
25
+ def _normalize_research_args(brief: str, depth: str, analysts, scope):
26
+ """Validate + clamp research() args at the MCP trust boundary. Returns
27
+ (brief, depth, analysts, scope, error): on a bad brief, error is a clean string and the rest
28
+ are unset; otherwise error is "". Every out-of-range value is clamped to a safe default rather
29
+ than raising — an MCP client never sees a traceback."""
30
+ from council.sanitize import sanitize_brief
31
+ brief = sanitize_brief(brief)
32
+ if not brief:
33
+ return "", "", 2, "both", "error: empty brief — provide a question to research."
34
+ depth = depth if depth in ("quick", "standard", "deep") else "quick"
35
+ scope = scope if scope in ("both", "web", "local") else "both"
36
+ try:
37
+ analysts = max(1, min(4, int(analysts)))
38
+ except (TypeError, ValueError):
39
+ analysts = 2
40
+ return brief, depth, analysts, scope, ""
41
+
42
+
43
+ def build_server():
44
+ from mcp.server.fastmcp import FastMCP
45
+
46
+ mcp = FastMCP("passive-workers")
47
+
48
+ @mcp.tool()
49
+ def research(brief: str, depth: str = "quick", analysts: int = 2,
50
+ scope: str = "both") -> str:
51
+ """Run multi-model local deep research (live web + your private library) and return a
52
+ cited markdown report. depth: quick|standard|deep. scope: both|web|local. Takes minutes."""
53
+ from council.local import run
54
+ brief, depth, analysts, scope, err = _normalize_research_args(brief, depth, analysts, scope)
55
+ if err:
56
+ return err
57
+ path = run(brief, depth=depth, n_analysts=analysts, scope=scope)
58
+ return path.read_text()
59
+
60
+ @mcp.tool()
61
+ def library_search(query: str, k: int = 5) -> str:
62
+ """Search your private document library; returns the top matching passages with sources."""
63
+ from council.library import Library
64
+ from council.sanitize import spotlight
65
+ hits = Library().search(query, k=max(1, min(20, int(k))))
66
+ if not hits:
67
+ return "(no matches — the library may be empty; add files with library_add)"
68
+ # document text is untrusted at this model-facing boundary → sanitize + spotlight
69
+ return "\n\n".join(f"[{h['title']}] (score {h['score']:.2f})\n{spotlight(h['text'][:800])}"
70
+ for h in hits)
71
+
72
+ @mcp.tool()
73
+ def library_add(path: str) -> str:
74
+ """Index a local file or directory (PDF/docx/txt/md) into your private library."""
75
+ from council.library import Library
76
+ n = Library().add(path)
77
+ return f"Indexed {n} chunks from {path}."
78
+
79
+ return mcp
80
+
81
+
82
+ def main() -> None:
83
+ build_server().run() # stdio transport by default
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
@@ -0,0 +1 @@
1
+ """Networked Council — coordinator service + worker agent (M2)."""
council/net/agent.py ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/agent.py — the networked worker daemon
4
+ ==================================================
5
+ Runs on each contributor machine (this Mac, the VPS, …). It:
6
+ • registers with the coordinator (declaring its model, lens, country, owner, judge ability),
7
+ • heartbeats so the coordinator knows it's online and how loaded it is,
8
+ • polls for tasks, runs them on its LOCAL Ollama, and submits OWNED results,
9
+ • handles two task types: `answer` (a perspective) and `judge` (score + merge).
10
+
11
+ It only ever DIALS OUT to the coordinator — no inbound connections to this machine.
12
+
13
+ Config via env (or flags):
14
+ PW_COORDINATOR e.g. http://VPS_IP:8088 (required)
15
+ PW_TOKEN shared secret (required)
16
+ PW_OWNER account that earns credit (default: hostname)
17
+ PW_NAME, PW_COUNTRY, PW_ANSWER_MODEL, PW_LENS, PW_CAN_JUDGE, PW_JUDGE_MODEL
18
+
19
+ Run: python -m council.net.agent
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import platform
26
+ import signal
27
+ import socket
28
+ import sys
29
+ import threading
30
+ import time
31
+
32
+ import requests
33
+
34
+ from council.judge import Judge
35
+ from council.sanitize import sanitize_brief
36
+ from council.worker import Answer, PerspectiveWorker
37
+
38
+ try:
39
+ import psutil
40
+ except Exception: # psutil optional
41
+ psutil = None
42
+
43
+
44
+ def _env(k: str, default: str = "") -> str:
45
+ return os.environ.get(k, default)
46
+
47
+
48
+ class Agent:
49
+ def __init__(self):
50
+ self.base = _env("PW_COORDINATOR").rstrip("/")
51
+ self.token = _env("PW_TOKEN", "dev-token")
52
+ if not self.base:
53
+ raise SystemExit("set PW_COORDINATOR (e.g. http://127.0.0.1:8088)")
54
+ host = socket.gethostname()
55
+ self.machine_id = _env("PW_MACHINE_ID", host) # processes on one computer share this
56
+ self.owner = _env("PW_OWNER", host)
57
+ self.name = _env("PW_NAME", host)
58
+ self.country = _env("PW_COUNTRY", "local")
59
+ self.answer_model = _env("PW_ANSWER_MODEL", "gemma3:4b")
60
+ self.lens = _env("PW_LENS", "neutral")
61
+ self.can_judge = _env("PW_CAN_JUDGE", "0") in ("1", "true", "True")
62
+ self.judge_model = _env("PW_JUDGE_MODEL", self.answer_model if self.can_judge else "")
63
+ self.poll_s = float(_env("PW_POLL", "2"))
64
+ self.node_id: str | None = None
65
+ self.node_secret: str | None = None
66
+ self._running = True
67
+
68
+ # ------------------------------------------------------------------ http
69
+ def _headers(self) -> dict:
70
+ return {"X-PW-Token": self.token}
71
+
72
+ def _node_headers(self) -> dict:
73
+ h = {"X-PW-Token": self.token}
74
+ if self.node_secret:
75
+ h["X-Node-Secret"] = self.node_secret
76
+ return h
77
+
78
+ def _profile(self) -> dict:
79
+ prof = {"os": platform.system(), "machine": platform.machine()}
80
+ if psutil:
81
+ prof["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
82
+ prof["cores"] = psutil.cpu_count(logical=False) or psutil.cpu_count()
83
+ try: # capability matching (D15 v1): which models this node can actually run
84
+ r = requests.get("http://localhost:11434/api/tags", timeout=5)
85
+ prof["models"] = sorted(m["name"] for m in r.json().get("models", []))[:40]
86
+ except Exception:
87
+ prof["models"] = []
88
+ return prof
89
+
90
+ def register(self) -> None:
91
+ body = {
92
+ "name": self.name, "country": self.country, "owner": self.owner,
93
+ "answer_model": self.answer_model, "lens": self.lens,
94
+ "can_judge": self.can_judge, "judge_model": self.judge_model,
95
+ "machine_id": self.machine_id, "profile": self._profile(),
96
+ }
97
+ r = requests.post(f"{self.base}/nodes/register", json=body, headers=self._headers(), timeout=15)
98
+ r.raise_for_status()
99
+ data = r.json()
100
+ self.node_id = data["node_id"]
101
+ self.node_secret = data.get("node_secret")
102
+ print(f"[agent] registered {self.name} ({self.answer_model}/{self.lens}/{self.country}) "
103
+ f"judge={self.can_judge} → node {self.node_id[:8]}… @ {self.base}")
104
+
105
+ def heartbeat(self) -> None:
106
+ load = (psutil.cpu_percent(interval=None) / 100.0) if psutil else 0.0
107
+ try:
108
+ r = requests.post(f"{self.base}/nodes/heartbeat", json={"load": load},
109
+ headers=self._node_headers(), timeout=10)
110
+ if r.status_code in (401, 404): # coordinator restarted / forgot us
111
+ self.register()
112
+ except requests.RequestException as exc:
113
+ print(f"[agent] heartbeat failed: {exc}")
114
+
115
+ # ------------------------------------------------------------------ task handlers
116
+ def _do_answer(self, task: dict) -> dict:
117
+ payload = task.get("payload") or {}
118
+ # defense-in-depth (D26): the coordinator is not fully trusted (cf. D25) — re-scrub the
119
+ # brief/instruction here so a hostile coordinator can't slip a hidden payload into a prompt.
120
+ question = sanitize_brief(payload.get("question", ""))
121
+ if payload.get("job_type") == "shard_map":
122
+ # D13: batch shard — apply the instruction to THIS node's slice of the items.
123
+ from council.batch import BatchWorker
124
+ bw = BatchWorker(self.node_id, self.answer_model,
125
+ country=task.get("country", self.country))
126
+ return bw.process(question, payload.get("shard") or [],
127
+ fetch=bool(payload.get("fetch")))
128
+ if payload.get("job_type") == "research_report" \
129
+ and os.environ.get("PW_WEB_BACKEND", "off") != "off":
130
+ # D13: async deep-research job — this node's own multi-round, egress-localized
131
+ # research with citations (council/researcher.py).
132
+ from council.researcher import ResearchWorker
133
+ rw = ResearchWorker(self.node_id, self.answer_model,
134
+ lens=task.get("lens", self.lens),
135
+ country=task.get("country", self.country),
136
+ scope="web") # federation = web only; no operator's private library
137
+ return rw.research(question)
138
+ web = None
139
+ if os.environ.get("PW_WEB_BACKEND", "off") != "off":
140
+ try:
141
+ from council.research import search as web # egress-localized web research
142
+ except Exception as exc:
143
+ print(f"[agent] web research unavailable: {exc}")
144
+ web = None
145
+ w = PerspectiveWorker(self.node_id, self.answer_model, lens=task.get("lens", self.lens),
146
+ country=task.get("country", self.country), web_search=web,
147
+ num_predict=int(os.environ.get("PW_NUM_PREDICT", "400")))
148
+ a = w.answer(question)
149
+ return {"text": a.text, "tokens": a.tokens, "elapsed_s": round(a.elapsed_s, 2)}
150
+
151
+ def _do_judge(self, task: dict) -> dict:
152
+ payload = task["payload"]
153
+ question = sanitize_brief(payload.get("question", "")) # defense-in-depth (D26), see _do_answer
154
+ answers = [
155
+ Answer(worker_id=x["worker_id"], model=x.get("model", ""), lens=x.get("lens", ""),
156
+ country=x.get("country", ""), text=x["text"], tokens=0, elapsed_s=0.0)
157
+ for x in payload["answers"]
158
+ ]
159
+ judge = Judge(model=self.judge_model or self.answer_model)
160
+ if payload.get("job_type") == "shard_map":
161
+ # Batch QA: spot-check sampled outputs per node; the store assembles the merged
162
+ # deliverable from the shards itself.
163
+ return judge.spot_check(question, payload["answers"])
164
+ out = judge.deliberate(question, answers) # scores + merge + council read
165
+ if payload.get("job_type") == "research_report":
166
+ # Editor pass: merged becomes the full cited multi-country report.
167
+ out["merged"] = judge.compile_report(question, payload["answers"], out)
168
+ return out
169
+
170
+ # ------------------------------------------------------------------ loop
171
+ def _heartbeat_loop(self) -> None:
172
+ """Heartbeat in the background so a node stays 'alive' even mid-inference."""
173
+ while self._running:
174
+ self.heartbeat()
175
+ time.sleep(self.poll_s)
176
+
177
+ def run(self) -> None:
178
+ # Never die because the coordinator/tunnel is briefly down at boot — keep trying.
179
+ while self._running:
180
+ try:
181
+ self.register()
182
+ break
183
+ except requests.RequestException as exc:
184
+ print(f"[agent] register failed ({exc}); retrying in 10s…")
185
+ time.sleep(10)
186
+ threading.Thread(target=self._heartbeat_loop, daemon=True, name="pw-hb").start()
187
+ while self._running:
188
+ try:
189
+ r = requests.get(f"{self.base}/tasks/next",
190
+ headers=self._node_headers(), timeout=20)
191
+ except requests.RequestException as exc:
192
+ print(f"[agent] poll failed: {exc}")
193
+ time.sleep(self.poll_s)
194
+ continue
195
+ if r.status_code == 401: # secret invalid; the heartbeat loop re-registers
196
+ time.sleep(self.poll_s)
197
+ continue
198
+ if r.status_code == 204 or not r.content:
199
+ time.sleep(self.poll_s)
200
+ continue
201
+ task = r.json()
202
+ kind = task["type"]
203
+ print(f"[agent] {kind} task {task['task_id'][:8]}… …")
204
+ t0 = time.monotonic()
205
+ try:
206
+ result = self._do_answer(task) if kind == "answer" else self._do_judge(task)
207
+ except Exception as exc:
208
+ print(f"[agent] task {task['task_id'][:8]} FAILED: {exc}")
209
+ result = {"text": "", "error": str(exc), "scores": {}, "merged": ""}
210
+ try:
211
+ requests.post(f"{self.base}/tasks/{task['task_id']}/result", json=result,
212
+ headers=self._node_headers(), timeout=30)
213
+ except requests.RequestException as exc:
214
+ print(f"[agent] result POST failed (task {task['task_id'][:8]}): {exc}")
215
+ print(f"[agent] {kind} done in {time.monotonic() - t0:.0f}s")
216
+
217
+ def stop(self, *_):
218
+ print("\n[agent] shutting down…")
219
+ self._running = False
220
+
221
+
222
+ def main() -> int:
223
+ agent = Agent()
224
+ signal.signal(signal.SIGINT, agent.stop)
225
+ signal.signal(signal.SIGTERM, agent.stop)
226
+ agent.run()
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(main())