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/__init__.py +1 -0
- council/artifacts.py +161 -0
- council/batch.py +84 -0
- council/cli.py +54 -0
- council/coordinator.py +133 -0
- council/crypto.py +133 -0
- council/fidelity.py +197 -0
- council/judge.py +393 -0
- council/ledger.py +230 -0
- council/library.py +431 -0
- council/local.py +228 -0
- council/mcp_server.py +87 -0
- council/net/__init__.py +1 -0
- council/net/agent.py +231 -0
- council/net/app.py +390 -0
- council/net/baseline.py +86 -0
- council/net/config.py +79 -0
- council/net/coordinator_app.py +370 -0
- council/net/dashboard.py +111 -0
- council/net/store.py +964 -0
- council/net/submit.py +102 -0
- council/operator.py +412 -0
- council/research.py +520 -0
- council/researcher.py +300 -0
- council/retrieval.py +80 -0
- council/run_demo.py +175 -0
- council/sanitize.py +78 -0
- council/serve.py +183 -0
- council/trust.py +168 -0
- council/worker.py +123 -0
- passiveworkers-0.1.0.dist-info/METADATA +269 -0
- passiveworkers-0.1.0.dist-info/RECORD +36 -0
- passiveworkers-0.1.0.dist-info/WHEEL +5 -0
- passiveworkers-0.1.0.dist-info/entry_points.txt +2 -0
- passiveworkers-0.1.0.dist-info/licenses/LICENSE +21 -0
- passiveworkers-0.1.0.dist-info/top_level.txt +1 -0
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()
|
council/net/__init__.py
ADDED
|
@@ -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())
|