"""Boardroom Map reviewer — a sandboxed, ONE-SHOT agent that reads confidential documents through a LOCAL model and writes a single report, then exits. Unlike a swarm worker, this never loops forever and never writes to a shared workspace. It mounts the documents read-only at /docs, runs one model (through the on-Spark proxy) under its PERSONA + the shared RUBRIC, and writes exactly one file to /out: role=reviewer -> /out/.md role=synthesizer -> /out/CONSOLIDATED_REPORT.md (also reads /reports) It speaks the OpenAI-compatible /v1/chat/completions API directly (no Claude CLI, no Anthropic translation — small local models handle this far better). The whole document set is pre-loaded into the prompt up to a budget; for anything larger the model can pull more with read_file. Native tool_calls are used when available, with a JSON-action text fallback for models without a vLLM tool parser. In air-gapped mode the container is on an --internal Docker network: the only thing reachable is the model proxy. web_search is offered ONLY when BM_SEARXNG_URL is set (local-services mode). """ from __future__ import annotations import json import os import re import ssl import time import traceback import urllib.parse import urllib.request _SSL_CTX = ssl._create_unverified_context() # LAN self-signed (SearXNG) RID = os.environ.get("BM_REVIEWER_ID", "reviewer") NAME = os.environ.get("BM_REVIEWER_NAME", RID) ROLE = os.environ.get("BM_ROLE", "reviewer") # reviewer | synthesizer MODEL = os.environ.get("BM_MODEL", "reviewer-a") LLM_BASE = os.environ.get("BM_LLM_BASE", "http://boardroom-proxy:4000/v1").rstrip("/") LLM_KEY = os.environ.get("BM_LLM_KEY", "sk-local") SEARXNG_URL = os.environ.get("BM_SEARXNG_URL", "").rstrip("/") try: TEMPERATURE = float(os.environ.get("BM_TEMPERATURE", "") or "0.3") except ValueError: TEMPERATURE = 0.3 try: MAX_MODEL_LEN = int(os.environ.get("BM_MAX_MODEL_LEN", "32768")) except ValueError: MAX_MODEL_LEN = 32768 DOCS = "/docs" REPORTS = "/reports" OUT_DIR = "/out" PERSONA_PATH = "/persona/PERSONA.md" RUBRIC_PATH = "/RUBRIC.md" # Leave headroom for the system/rubric/persona + the model's output; spend the # rest on document text (~3 chars/token is a safe rough estimate). DOC_BUDGET = max(8000, (MAX_MODEL_LEN - 3500) * 3) MAX_STEPS = 8 MAX_OUTPUT_TOKENS = 2048 # ---------------------------------------------------------------- io helpers def read_text(path: str, limit: int = 1_000_000) -> str: try: with open(path, errors="replace") as f: return f.read()[:limit] except FileNotFoundError: return "" def _roots() -> list[str]: return [DOCS, REPORTS] if ROLE == "synthesizer" else [DOCS] def _safe(path: str) -> str: """Resolve a path inside an allowed read root; refuse escapes.""" cand = path or "." for root in _roots(): p = os.path.realpath(os.path.join(root, cand) if not os.path.isabs(cand) else cand) if p == root or p.startswith(root + os.sep): return p raise ValueError(f"path outside allowed roots: {path}") def list_dir(root: str) -> list[str]: out = [] if not os.path.isdir(root): return out for r, _dirs, files in os.walk(root): for fn in files: out.append(os.path.relpath(os.path.join(r, fn), root)) return sorted(out) # ---------------------------------------------------------------- tools def tool_list_files(args: dict) -> str: lines = [] for root in _roots(): names = list_dir(root) if names: lines.append(f"{root}:") lines += [f" {n}" for n in names] return "\n".join(lines) or "(no files)" def tool_read_file(args: dict) -> str: p = _safe(args["path"]) try: with open(p, errors="replace") as f: return f.read()[:20000] except FileNotFoundError: return f"(no such file: {args['path']})" except IsADirectoryError: return f"(is a directory: {args['path']})" def tool_web_search(args: dict) -> str: if not SEARXNG_URL: return "web search unavailable" q = urllib.parse.urlencode({"q": args.get("query", ""), "format": "json"}) try: req = urllib.request.Request(f"{SEARXNG_URL}/search?{q}", headers={"User-Agent": "boardroom-grader"}) with urllib.request.urlopen(req, timeout=20, context=_SSL_CTX) as resp: data = json.loads(resp.read().decode()) lines = [f"- {r.get('title','')}\n {r.get('url','')}\n {r.get('content','')[:300]}" for r in (data.get("results") or [])[:8]] return "\n".join(lines) or "(no results)" except Exception as e: return f"search error: {e}" DISPATCH = {"list_files": tool_list_files, "read_file": tool_read_file, "web_search": tool_web_search} TOOLS = [ {"type": "function", "function": { "name": "list_files", "description": "List the available document (and report) files.", "parameters": {"type": "object", "properties": {}}}}, {"type": "function", "function": { "name": "read_file", "description": "Read a document or report file by its path (from list_files).", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}}, ] if SEARXNG_URL: TOOLS.append({"type": "function", "function": { "name": "web_search", "description": "Search the web via SearXNG; returns top results.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}}) def run_tool(name: str, args: dict) -> str: fn = DISPATCH.get(name) if not fn: return f"(unknown tool: {name})" try: return fn(args) except Exception as e: return f"(tool error: {e})" # ---------------------------------------------------------------- LLM def chat(messages: list) -> dict: body = {"model": MODEL, "messages": messages, "tools": TOOLS, "tool_choice": "auto", "temperature": TEMPERATURE, "max_tokens": MAX_OUTPUT_TOKENS} req = urllib.request.Request( f"{LLM_BASE}/chat/completions", data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM_KEY}"}, method="POST") with urllib.request.urlopen(req, timeout=300, context=_SSL_CTX) as resp: data = json.loads(resp.read().decode()) return data["choices"][0]["message"] _JSON_ACTION = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL) def _text_fallback_calls(content: str) -> list: if not content: return [] m = _JSON_ACTION.search(content) if not m: return [] try: obj = json.loads(m.group(1)) except json.JSONDecodeError: return [] name = obj.get("tool") or obj.get("name") if name in DISPATCH: return [{"id": "fallback", "function": {"name": name, "arguments": json.dumps(obj.get("args", {}))}}] return [] # ---------------------------------------------------------------- prompts def _preload_docs() -> tuple[str, bool]: """Concatenate the document text up to DOC_BUDGET. Returns (text, truncated).""" names = list_dir(DOCS) chunks, used, truncated = [], 0, False for n in names: body = read_text(os.path.join(DOCS, n)) header = f"\n\n========== DOCUMENT: {n} ==========\n" room = DOC_BUDGET - used if room <= 0: truncated = True break seg = (header + body)[:room] if len(header + body) > room: truncated = True chunks.append(seg) used += len(seg) return "".join(chunks), truncated def _preload_reports() -> str: names = list_dir(REPORTS) parts = [] budget = DOC_BUDGET used = 0 for n in names: body = read_text(os.path.join(REPORTS, n)) header = f"\n\n========== REVIEWER REPORT: {n} ==========\n" seg = (header + body)[: max(0, budget - used)] parts.append(seg) used += len(seg) return "".join(parts) def system_prompt() -> str: persona = read_text(PERSONA_PATH).strip() if ROLE == "synthesizer": base = (f"You are '{NAME}', the lead reviewer chairing a document-review panel. " "You are given the panel members' individual reports (and the source " "documents for reference). Produce ONE consolidated report in Markdown.") else: base = (f"You are '{NAME}', an expert confidential-document reviewer. Read the " "document(s) provided and produce ONE written report in Markdown. Base every " "statement on the documents; never invent facts. Be specific and cite the " "document/section for each point.") if persona: base += "\n\n# YOUR LENS — how YOU specifically read this\n" + persona tools_note = ( "\n\nYou can call list_files and read_file to pull more content if what was " "pre-loaded is truncated" + (", and web_search for external context" if SEARXNG_URL else "") + ". When done, reply with the FINAL report only — no tool call. If your client " 'cannot emit tool calls, reply with a single fenced block: ' '```json\\n{"tool":"read_file","args":{"path":"..."}}\\n``` and nothing else.' ) return base + tools_note def first_user_message() -> str: rubric = read_text(RUBRIC_PATH).strip() or "Produce a thorough review report." if ROLE == "synthesizer": reports = _preload_reports() docs, truncated = _preload_docs() return (f"# REVIEW RUBRIC\n{rubric}\n\n# PANEL REPORTS\n{reports}\n\n" f"# SOURCE DOCUMENTS (for reference){' (truncated)' if truncated else ''}\n{docs}\n\n" "# YOUR TASK\nConsolidate the panel's reports into one authoritative report per the " "rubric: shared findings, conflicts (and your adjudication), anything only one " "reviewer caught, and a prioritized overall recommendation. Attribute points to " "reviewers. Output the final consolidated report now.") docs, truncated = _preload_docs() note = ("\n\n(Note: the documents were truncated to fit context — use read_file to pull any " "section you need in full.)" if truncated else "") return (f"# REVIEW RUBRIC\n{rubric}\n\n# DOCUMENT(S)\n{docs}{note}\n\n" "# YOUR TASK\nReview the document(s) above per the rubric and your lens. Output your " "final report now.") # ---------------------------------------------------------------- run def out_path() -> str: name = "CONSOLIDATED_REPORT.md" if ROLE == "synthesizer" else f"{RID}.md" return os.path.join(OUT_DIR, name) def write_report(text: str) -> None: os.makedirs(OUT_DIR, exist_ok=True) with open(out_path(), "w") as f: f.write(text.strip() + "\n") def run() -> str: messages = [{"role": "system", "content": system_prompt()}, {"role": "user", "content": first_user_message()}] last_text = "" for _ in range(MAX_STEPS): msg = chat(messages) content = msg.get("content") or "" calls = msg.get("tool_calls") or [] if content.strip(): last_text = content.strip() if not calls: calls = _text_fallback_calls(content) if not calls: break # final report messages.append({"role": "assistant", "content": content}) for c in calls: args = json.loads(c["function"]["arguments"] or "{}") res = run_tool(c["function"]["name"], args) messages.append({"role": "user", "content": f"[tool {c['function']['name']} result]\n{res[:20000]}"}) continue messages.append({"role": "assistant", "content": content or None, "tool_calls": calls}) for c in calls: try: args = json.loads(c["function"]["arguments"] or "{}") except json.JSONDecodeError: args = {} res = run_tool(c["function"]["name"], args) messages.append({"role": "tool", "tool_call_id": c.get("id", ""), "content": res[:20000]}) # If the model ended on a tool turn with no report text, ask once more plainly. if not last_text.strip(): messages.append({"role": "user", "content": "Now output your final report in Markdown."}) try: last_text = (chat(messages).get("content") or "").strip() except Exception: pass return last_text def main() -> None: print(f"[{RID}] reviewer starting (role={ROLE} model={MODEL} base={LLM_BASE})", flush=True) try: report = run() if not report.strip(): report = f"# {NAME}\n\n(The model returned no report text.)" write_report(report) print(f"[{RID}] report written to {out_path()} ({len(report)} chars)", flush=True) except Exception as e: print(traceback.format_exc(), flush=True) # Always leave a file so the orchestrator can see this reviewer ran. try: write_report(f"# {NAME} — ERROR\n\nThis reviewer failed: {e}\n") except Exception: pass raise if __name__ == "__main__": main()