from __future__ import annotations import argparse import json import os import re import sys import time import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path # Orchestrator client: drive a template/schematic on the hosted panel and # MATERIALIZE the result — the final body plus any multi-file artifacts — into a # real workspace so you (the orchestrator) reference files, not a wall of text. # # python tools/orchestrate_client.py --template research_paper \ # --input-file prompt.md --profile research --out runs/ # python tools/orchestrate_client.py --schematic-file my_schematic.json \ # --input "Build a CLI todo app in Python." --out runs/ # # URL + token default to $CRITIQUE_URL / $CRITIQUE_TOKEN. def _req(method, url, token, body=None, timeout=60.0): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"Bearer {token}") if data is not None: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, json.loads(r.read()) except urllib.error.HTTPError as e: try: return e.code, json.loads(e.read()) except Exception: return e.code, {"error": f"HTTP {e.code}"} except (urllib.error.URLError, TimeoutError) as e: return 0, {"error": f"network: {e}"} def _slug(s: str) -> str: return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "run" def submit(url, token, body, retries=2): for attempt in range(retries + 1): status, resp = _req("POST", f"{url}/api/run", token, body, timeout=120.0) if status in (200, 202): return status, resp if attempt < retries and status in (0, 503, 504): wait = 15 * (attempt + 1) print(f" submit failed ({status}); cold start? waiting {wait}s", file=sys.stderr) time.sleep(wait) continue raise SystemExit(f"submit failed: HTTP {status}: {resp.get('error')}") raise SystemExit("submit failed after retries") def poll(url, token, job_id, *, interval, timeout): deadline = time.time() + timeout last = None while time.time() < deadline: status, snap = _req("GET", f"{url}/api/jobs/{job_id}", token, timeout=60.0) if status == 200: last = snap for line in snap.get("stage_log", [])[-3:]: print(f" {line}", file=sys.stderr) if snap.get("status") == "complete": return snap.get("result") or {} time.sleep(interval) print(" timeout; returning last partial", file=sys.stderr) return (last or {}).get("result") or {} def _safe_under(root: Path, rel: str) -> Path | None: # defence in depth: never write outside the run dir even if the server # somehow returned an unsafe path. p = (root / rel).resolve() try: p.relative_to(root.resolve()) except ValueError: return None return p def materialize_run(result: dict, out_root: Path) -> Path: # write a run result into out_root/-/: each artifact file at its # path, plus _body.md, _judge.md, _manifest.json. returns the run dir. ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") out = out_root / f"{ts}-{_slug(result.get('task', 'run'))}" out.mkdir(parents=True, exist_ok=True) written = [] for f in result.get("artifacts", []): dest = _safe_under(out, f.get("path", "")) if dest is None: continue dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(f.get("content", ""), encoding="utf-8") written.append(str(dest.relative_to(out)) + (" (truncated)" if f.get("truncated") else "")) if result.get("body") and not result.get("artifacts"): (out / "_body.md").write_text(result["body"], encoding="utf-8") written.append("_body.md") judge = result.get("judge") or {} judge_md = "# Judge report\n\n" judge_md += "## Hard failures\n" + ("\n".join(f"- {h}" for h in judge.get("hard_fails", [])) or "- (none)") + "\n\n" judge_md += "## Soft flags\n" + ("\n".join(f"- {s}" for s in judge.get("soft_flags", [])) or "- (none)") + "\n" (out / "_judge.md").write_text(judge_md, encoding="utf-8") (out / "_manifest.json").write_text(json.dumps({ "task": result.get("task"), "ok": result.get("ok"), "rounds": result.get("rounds"), "error": result.get("error"), "stages": result.get("stages", []), "providers_used": result.get("providers_used", []), "artifacts": [f.get("path") for f in result.get("artifacts", [])], }, indent=2), encoding="utf-8") return out def main() -> int: ap = argparse.ArgumentParser(description="Run an orchestrator template/schematic; materialize the result as files.") ap.add_argument("--url", default=os.environ.get("CRITIQUE_URL", "")) ap.add_argument("--token", default=os.environ.get("CRITIQUE_TOKEN", "")) src = ap.add_mutually_exclusive_group(required=True) src.add_argument("--input", help="prompt text inline") src.add_argument("--input-file", help="file holding the prompt text") tpl = ap.add_mutually_exclusive_group() tpl.add_argument("--template", help="built-in template id (e.g. research_paper, freeform)") tpl.add_argument("--schematic-file", help="path to a custom schematic JSON") ap.add_argument("--profile", default="default") ap.add_argument("--effort", default="med") ap.add_argument("--out", default="runs") ap.add_argument("--sync", action="store_true", help="block instead of async+poll") ap.add_argument("--poll-interval", type=float, default=10.0) ap.add_argument("--timeout", type=float, default=3600.0) args = ap.parse_args() if not args.url or not args.token: raise SystemExit("set --url/--token or CRITIQUE_URL/CRITIQUE_TOKEN") prompt = Path(args.input_file).read_text(encoding="utf-8") if args.input_file else args.input body = {"prompt": prompt, "profile": args.profile, "effort": args.effort, "async": not args.sync} if args.template: body["template"] = args.template if args.schematic_file: body["schematic"] = json.loads(Path(args.schematic_file).read_text(encoding="utf-8")) print(f"submitting run (template={args.template}, profile={args.profile})...", file=sys.stderr) status, resp = submit(args.url, args.token, body) if status == 200: # sync result = resp else: # 202 async print(f" job_id={resp.get('job_id')}", file=sys.stderr) result = poll(args.url, args.token, resp["job_id"], interval=args.poll_interval, timeout=args.timeout) out = materialize_run(result, Path(args.out)) print(f"\nrun {'OK' if result.get('ok') else 'INCOMPLETE'} -> {out}") for f in sorted(out.rglob("*")): if f.is_file(): print(f" {f}") if not result.get("ok"): print(f"note: {result.get('error') or 'judge still has hard fails'}", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())