mirror of
https://github.com/hanzoskill/llm-council.git
synced 2026-10-30 21:14:09 +00:00
1454 lines
50 KiB
Python
1454 lines
50 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
import os
|
|
import queue
|
|
from pathlib import Path
|
|
import random
|
|
import re
|
|
import shutil
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
import webbrowser
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import ui_server
|
|
|
|
RETRY_LIMIT = 2
|
|
DEFAULT_TIMEOUT_SEC = 180
|
|
DEFAULT_UI_KEEPALIVE_SEC = 20 * 60
|
|
DEFAULT_UI_SESSION_TTL_SEC = 30 * 60
|
|
|
|
CODEX_MODEL = "gpt-5.2-codex"
|
|
CODEX_REASONING = "xhigh"
|
|
CLAUDE_MODEL = "opus"
|
|
GEMINI_MODEL = "gemini-3-pro-preview"
|
|
|
|
@dataclass
|
|
class AgentConfig:
|
|
name: str
|
|
kind: str
|
|
command: Optional[str] = None
|
|
output_format: str = "text"
|
|
model: Optional[str] = None
|
|
reasoning_effort: Optional[str] = None
|
|
agent: Optional[str] = None
|
|
attach: Optional[str] = None
|
|
cli_format: Optional[str] = None
|
|
prompt_mode: str = "arg"
|
|
extra_args: List[str] = field(default_factory=list)
|
|
|
|
@dataclass
|
|
class AgentResult:
|
|
name: str
|
|
raw_output: str
|
|
data: Optional[Dict[str, Any]]
|
|
valid: bool
|
|
error: Optional[str]
|
|
|
|
|
|
@dataclass
|
|
class RunningAgent:
|
|
config: AgentConfig
|
|
prompt: str
|
|
start_time: float
|
|
process: Any
|
|
|
|
|
|
def load_json(path: str) -> Dict[str, Any]:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def write_json(path: str, payload: Dict[str, Any]) -> None:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, sort_keys=True)
|
|
|
|
|
|
def extract_json(text: str) -> Optional[Dict[str, Any]]:
|
|
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return json.loads(match.group(0))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def extract_json_array(text: str) -> Optional[List[Any]]:
|
|
start = text.find("[")
|
|
end = text.rfind("]")
|
|
if start == -1 or end == -1 or end <= start:
|
|
return None
|
|
try:
|
|
return json.loads(text[start : end + 1])
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def extract_agent_response(config: AgentConfig, raw: str) -> str:
|
|
kind = (config.kind or config.name).lower()
|
|
if kind == "codex":
|
|
for line in raw.splitlines():
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(event, dict):
|
|
continue
|
|
kind = event.get("event") or event.get("type")
|
|
if kind == "turn.completed":
|
|
content = event.get("content")
|
|
if isinstance(content, str):
|
|
return content
|
|
message = event.get("message")
|
|
if isinstance(message, dict):
|
|
msg_content = message.get("content")
|
|
if isinstance(msg_content, str):
|
|
return msg_content
|
|
if kind == "item.completed":
|
|
item = event.get("item")
|
|
if isinstance(item, dict):
|
|
if item.get("type") in ("agent_message", "assistant_message"):
|
|
text = item.get("text")
|
|
if isinstance(text, str):
|
|
return text
|
|
return raw
|
|
|
|
if kind == "claude":
|
|
events = extract_json_array(raw)
|
|
if events is None:
|
|
return raw
|
|
if isinstance(events, list):
|
|
for item in reversed(events):
|
|
if isinstance(item, dict) and item.get("type") == "result":
|
|
result = item.get("result")
|
|
if isinstance(result, str):
|
|
return result
|
|
for item in reversed(events):
|
|
if isinstance(item, dict) and item.get("type") == "assistant":
|
|
msg = item.get("message")
|
|
if isinstance(msg, dict):
|
|
content_list = msg.get("content")
|
|
if isinstance(content_list, list):
|
|
for block in content_list:
|
|
if isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
return block["text"]
|
|
return raw
|
|
|
|
if kind == "gemini":
|
|
envelope = extract_json(raw)
|
|
if envelope is None:
|
|
try:
|
|
envelope = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return raw
|
|
if isinstance(envelope, dict):
|
|
for key in ("response", "completion", "content", "output", "text"):
|
|
value = envelope.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
content = envelope.get("content")
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
return item["text"]
|
|
return raw
|
|
|
|
if kind == "opencode":
|
|
# Prefer OpenCode JSON event stream output when --format json is used.
|
|
text_parts: List[str] = []
|
|
for line in raw.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(event, dict):
|
|
continue
|
|
direct_text = event.get("text")
|
|
if isinstance(direct_text, str):
|
|
text_parts.append(direct_text)
|
|
continue
|
|
part = event.get("part")
|
|
if isinstance(part, dict):
|
|
part_text = part.get("text")
|
|
if isinstance(part_text, str):
|
|
text_parts.append(part_text)
|
|
if text_parts:
|
|
return "".join(text_parts).strip()
|
|
envelope = extract_json(raw)
|
|
if envelope is None:
|
|
try:
|
|
envelope = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
envelope = None
|
|
if isinstance(envelope, dict):
|
|
for key in ("response", "completion", "content", "output", "text", "message"):
|
|
value = envelope.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
content = envelope.get("content")
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
return item["text"]
|
|
if isinstance(envelope, list):
|
|
for item in reversed(envelope):
|
|
if isinstance(item, dict):
|
|
for key in ("content", "text", "message", "output"):
|
|
value = item.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
return raw
|
|
|
|
return raw
|
|
|
|
|
|
def _build_command_and_input(config: AgentConfig, prompt: str) -> Tuple[List[str], Optional[str]]:
|
|
kind = (config.kind or config.name).lower()
|
|
if kind == "codex":
|
|
model = config.model or CODEX_MODEL
|
|
reasoning = config.reasoning_effort or CODEX_REASONING
|
|
args = [
|
|
"codex",
|
|
"exec",
|
|
"--json",
|
|
"-m",
|
|
model,
|
|
"-c",
|
|
f"model_reasoning_effort={reasoning}",
|
|
]
|
|
args.extend(config.extra_args)
|
|
args.append(prompt)
|
|
return (
|
|
args,
|
|
None,
|
|
)
|
|
if kind == "gemini":
|
|
model = config.model or GEMINI_MODEL
|
|
args = ["gemini", "--output-format", "json"]
|
|
if model:
|
|
args.extend(["--model", model])
|
|
args.extend(config.extra_args)
|
|
args.extend(["-p", prompt])
|
|
return (
|
|
args,
|
|
None,
|
|
)
|
|
if kind == "claude":
|
|
model = config.model or CLAUDE_MODEL
|
|
args = [
|
|
"claude",
|
|
"--output-format",
|
|
"json",
|
|
"--model",
|
|
model,
|
|
"--max-turns",
|
|
"1",
|
|
"--no-session-persistence",
|
|
"--dangerously-skip-permissions",
|
|
"--tools",
|
|
"",
|
|
"--disable-slash-commands",
|
|
]
|
|
args.extend(config.extra_args)
|
|
args.extend(["-p", prompt])
|
|
return (
|
|
args,
|
|
None,
|
|
)
|
|
if kind == "opencode":
|
|
args = ["opencode", "run"]
|
|
args.extend(config.extra_args)
|
|
if config.model:
|
|
args.extend(["--model", config.model])
|
|
if config.agent:
|
|
args.extend(["--agent", config.agent])
|
|
if config.cli_format:
|
|
args.extend(["--format", config.cli_format])
|
|
if config.attach:
|
|
args.extend(["--attach", config.attach])
|
|
args.append(prompt)
|
|
return (args, None)
|
|
if not config.command:
|
|
raise ValueError(f"custom agent '{config.name}' requires a command")
|
|
args = shlex.split(config.command)
|
|
if config.extra_args:
|
|
args.extend(config.extra_args)
|
|
if (config.prompt_mode or "stdin").lower() == "stdin":
|
|
return (args, prompt + "\n")
|
|
return (args + [prompt], None)
|
|
|
|
|
|
def spawn_cli_agent(config: AgentConfig, prompt: str) -> RunningAgent:
|
|
args, stdin_payload = _build_command_and_input(config, prompt)
|
|
process = subprocess.Popen(
|
|
args,
|
|
stdin=subprocess.PIPE if stdin_payload is not None else None,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
start_new_session=True,
|
|
)
|
|
if stdin_payload is not None and process.stdin:
|
|
process.stdin.write(stdin_payload)
|
|
process.stdin.close()
|
|
return RunningAgent(config=config, prompt=prompt, start_time=time.time(), process=process)
|
|
|
|
|
|
def collect_cli_output(running: RunningAgent, timeout_sec: int) -> str:
|
|
try:
|
|
stdout, stderr = running.process.communicate(timeout=timeout_sec)
|
|
except subprocess.TimeoutExpired as exc:
|
|
running.process.kill()
|
|
stdout, stderr = running.process.communicate()
|
|
raise TimeoutError(f"{running.config.name} timed out") from exc
|
|
combined = stdout or ""
|
|
if stderr:
|
|
combined = combined + "\n" + stderr
|
|
return combined
|
|
|
|
|
|
def anonymize_text(text: str) -> str:
|
|
patterns = [
|
|
r"codex",
|
|
r"claude",
|
|
r"gemini",
|
|
r"opencode",
|
|
r"openai",
|
|
r"anthropic",
|
|
r"google",
|
|
r"gpt[-_\\w]*",
|
|
r"sk-[A-Za-z0-9]{10,}",
|
|
r"system prompt",
|
|
r"tool trace",
|
|
r"trace id",
|
|
]
|
|
pattern = re.compile("|".join(patterns), flags=re.IGNORECASE)
|
|
return pattern.sub("[REDACTED]", text)
|
|
|
|
|
|
def validate_markdown_plan(text: str) -> Tuple[bool, Optional[str]]:
|
|
required = [
|
|
"# Plan",
|
|
"## Overview",
|
|
"## Scope",
|
|
"## Phases",
|
|
"## Testing Strategy",
|
|
"## Risks",
|
|
"## Rollback Plan",
|
|
"## Edge Cases",
|
|
]
|
|
missing = [header for header in required if header not in text]
|
|
if missing:
|
|
return False, "missing headers: " + ", ".join(missing)
|
|
return True, None
|
|
|
|
|
|
def validate_markdown_judge(text: str) -> Tuple[bool, Optional[str]]:
|
|
required = [
|
|
"# Judge Report",
|
|
"## Scores",
|
|
"## Comparative Analysis",
|
|
"## Missing Steps",
|
|
"## Contradictions",
|
|
"## Improvements",
|
|
"## Final Plan",
|
|
]
|
|
missing = [header for header in required if header not in text]
|
|
if missing:
|
|
return False, "missing headers: " + ", ".join(missing)
|
|
return True, None
|
|
|
|
|
|
def _ui_timestamp() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _ui_deadline_from_now(seconds: int) -> str:
|
|
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
|
|
|
|
|
|
def _ui_truncate(text: str, max_len: int = 600) -> str:
|
|
cleaned = (text or "").strip()
|
|
if not cleaned:
|
|
return ""
|
|
if len(cleaned) <= max_len:
|
|
return cleaned
|
|
return cleaned[:max_len].rstrip() + "…"
|
|
|
|
|
|
def _ui_update_timestamp(state: Dict[str, Any], timestamp: str) -> None:
|
|
timestamps = state.get("timestamps")
|
|
if not isinstance(timestamps, dict):
|
|
timestamps = {}
|
|
if "started_at" not in timestamps:
|
|
timestamps["started_at"] = timestamp
|
|
timestamps["updated_at"] = timestamp
|
|
state["timestamps"] = timestamps
|
|
|
|
|
|
def _ui_emit(ui_instance: Optional["ui_server.UIServer"], event_type: str, payload: Dict[str, Any]) -> None:
|
|
if not ui_instance:
|
|
return
|
|
ui_instance.broadcast({"type": event_type, "payload": payload})
|
|
|
|
|
|
def _ui_set_session_state(
|
|
ui_state: Optional["ui_server.UIState"],
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
keep_open: bool,
|
|
deadline: Optional[str],
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_state:
|
|
return
|
|
def mutator(state: Dict[str, Any]) -> None:
|
|
state["keep_open"] = keep_open
|
|
state["ui_deadline"] = deadline or ""
|
|
_ui_update_timestamp(state, timestamp)
|
|
ui_state.mutate(mutator)
|
|
_ui_emit(
|
|
ui_instance,
|
|
"session_update",
|
|
{"keep_open": keep_open, "ui_deadline": deadline or "", "timestamp": timestamp},
|
|
)
|
|
|
|
|
|
def _ui_set_phase(
|
|
ui_state: Optional["ui_server.UIState"],
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
phase: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_state:
|
|
return
|
|
def mutator(state: Dict[str, Any]) -> None:
|
|
state["phase"] = phase
|
|
_ui_update_timestamp(state, timestamp)
|
|
ui_state.mutate(mutator)
|
|
_ui_emit(ui_instance, "phase_change", {"phase": phase, "timestamp": timestamp})
|
|
|
|
|
|
def _ui_upsert_planner(
|
|
ui_state: Optional["ui_server.UIState"],
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
planner_id: str,
|
|
status: str,
|
|
summary: str,
|
|
errors: Optional[List[str]],
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_state:
|
|
return
|
|
entry = {"id": planner_id, "status": status, "summary": summary, "errors": errors or []}
|
|
def mutator(state: Dict[str, Any]) -> None:
|
|
planners = state.get("planners")
|
|
if not isinstance(planners, list):
|
|
planners = []
|
|
index = next((i for i, item in enumerate(planners) if item.get("id") == planner_id), None)
|
|
if index is None:
|
|
planners.append(entry)
|
|
else:
|
|
planners[index] = entry
|
|
state["planners"] = planners
|
|
_ui_update_timestamp(state, timestamp)
|
|
ui_state.mutate(mutator)
|
|
_ui_emit(ui_instance, "planner_update", {"planner": entry, "timestamp": timestamp})
|
|
|
|
|
|
def _ui_update_judge(
|
|
ui_state: Optional["ui_server.UIState"],
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
status: str,
|
|
summary: str,
|
|
errors: Optional[List[str]],
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_state:
|
|
return
|
|
judge_entry = {"status": status, "summary": summary, "errors": errors or []}
|
|
def mutator(state: Dict[str, Any]) -> None:
|
|
state["judge"] = judge_entry
|
|
_ui_update_timestamp(state, timestamp)
|
|
ui_state.mutate(mutator)
|
|
_ui_emit(ui_instance, "judge_update", {"judge": judge_entry, "timestamp": timestamp})
|
|
|
|
|
|
def _ui_set_final_plan(
|
|
ui_state: Optional["ui_server.UIState"],
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
final_plan: str,
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_state:
|
|
return
|
|
def mutator(state: Dict[str, Any]) -> None:
|
|
state["final_plan"] = final_plan
|
|
_ui_update_timestamp(state, timestamp)
|
|
ui_state.mutate(mutator)
|
|
_ui_emit(ui_instance, "final_plan", {"final_plan": final_plan, "timestamp": timestamp})
|
|
|
|
|
|
def _ui_action_result(
|
|
ui_instance: Optional["ui_server.UIServer"],
|
|
action: str,
|
|
status: str,
|
|
message: str,
|
|
url: Optional[str],
|
|
timestamp: str,
|
|
) -> None:
|
|
if not ui_instance:
|
|
return
|
|
payload = {"action": action, "status": status, "message": message, "timestamp": timestamp}
|
|
if url:
|
|
payload["url"] = url
|
|
_ui_emit(ui_instance, "action_result", payload)
|
|
|
|
|
|
class _KeepaliveController:
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self.keep_open = False
|
|
|
|
def set_keep_open(self, value: bool) -> None:
|
|
with self._lock:
|
|
self.keep_open = value
|
|
|
|
def should_keep_open(self) -> bool:
|
|
with self._lock:
|
|
return self.keep_open
|
|
|
|
|
|
def _parse_ui_deadline(value: Optional[str]) -> Optional[datetime]:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _start_ui_session_timer(
|
|
ui_instance: "ui_server.UIServer",
|
|
ui_state: "ui_server.UIState",
|
|
stop_event: threading.Event,
|
|
keepalive: Optional[_KeepaliveController],
|
|
) -> None:
|
|
def run() -> None:
|
|
while not stop_event.is_set():
|
|
state = ui_state.get()
|
|
keep_open = bool(state.get("keep_open"))
|
|
if keepalive and keepalive.should_keep_open():
|
|
keep_open = True
|
|
if not keep_open:
|
|
deadline = _parse_ui_deadline(state.get("ui_deadline"))
|
|
if deadline and datetime.now(timezone.utc) >= deadline:
|
|
_ui_action_result(
|
|
ui_instance,
|
|
"session",
|
|
"expired",
|
|
"session expired",
|
|
None,
|
|
_ui_timestamp(),
|
|
)
|
|
stop_event.set()
|
|
ui_instance.shutdown()
|
|
break
|
|
time.sleep(1)
|
|
|
|
thread = threading.Thread(target=run, name="ui-session-timer", daemon=True)
|
|
thread.start()
|
|
|
|
|
|
def _coerce_text(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def _build_refine_prompt(plan_template: str, task_brief: str, final_plan: str, context: str) -> str:
|
|
notes = context.strip() if context else "No extra context provided."
|
|
return (
|
|
"You are refining a plan. Return only updated plan markdown that follows the template below.\n\n"
|
|
f"Task brief:\n{task_brief}\n\n"
|
|
f"Template:\n{plan_template}\n\n"
|
|
f"Current plan:\n{final_plan}\n\n"
|
|
f"Refinement request:\n{notes}\n"
|
|
)
|
|
|
|
|
|
def _rebuild_ui_state_from_run(run_dir: Path) -> Dict[str, Any]:
|
|
planners = []
|
|
for plan_path in sorted(run_dir.glob("plan-*.md")):
|
|
name = plan_path.stem[len("plan-") :]
|
|
if name.endswith("-attempt1") or name.endswith("-attempt2") or name.endswith("-attempt3"):
|
|
continue
|
|
planners.append(
|
|
{
|
|
"id": name,
|
|
"status": "complete",
|
|
"summary": load_text(str(plan_path)),
|
|
"errors": [],
|
|
}
|
|
)
|
|
judge_path = run_dir / "judge.md"
|
|
final_path = run_dir / "final-plan.md"
|
|
return {
|
|
"run_id": run_dir.name,
|
|
"task_brief": "",
|
|
"phase": "complete",
|
|
"planners": planners,
|
|
"judge": {
|
|
"status": "complete" if judge_path.exists() else "unknown",
|
|
"summary": load_text(str(judge_path)) if judge_path.exists() else "",
|
|
"errors": [],
|
|
},
|
|
"final_plan": load_text(str(final_path)) if final_path.exists() else "",
|
|
"errors": [],
|
|
"keep_open": False,
|
|
"ui_deadline": _ui_deadline_from_now(DEFAULT_UI_SESSION_TTL_SEC),
|
|
"timestamps": {"started_at": "", "updated_at": _ui_timestamp()},
|
|
}
|
|
|
|
|
|
def _next_numbered_final_plan_path(run_dir: Path) -> Path:
|
|
pattern = re.compile(r"^final-plan-(\d+)\.md$")
|
|
max_num = 0
|
|
for path in run_dir.glob("final-plan-*.md"):
|
|
match = pattern.match(path.name)
|
|
if not match:
|
|
continue
|
|
max_num = max(max_num, int(match.group(1)))
|
|
return run_dir / f"final-plan-{max_num + 1}.md"
|
|
|
|
|
|
def _handle_ui_actions(
|
|
ui_instance: "ui_server.UIServer",
|
|
ui_state: Optional["ui_server.UIState"],
|
|
run_dir: Path,
|
|
task_spec: Dict[str, Any],
|
|
args: argparse.Namespace,
|
|
config_path: Path,
|
|
stop_event: threading.Event,
|
|
keepalive: Optional[_KeepaliveController] = None,
|
|
judge: Optional[AgentConfig] = None,
|
|
plan_template: Optional[str] = None,
|
|
) -> None:
|
|
while not stop_event.is_set():
|
|
try:
|
|
action = ui_instance.actions.get(timeout=0.5)
|
|
except queue.Empty:
|
|
continue
|
|
try:
|
|
path = action.path
|
|
payload = action.payload or {}
|
|
if path == "/api/save":
|
|
final_plan = _coerce_text(payload.get("final_plan"))
|
|
save_path = _next_numbered_final_plan_path(run_dir)
|
|
save_path.write_text(final_plan, encoding="utf-8")
|
|
_ui_action_result(
|
|
ui_instance,
|
|
"save",
|
|
"saved",
|
|
f"Saved at {save_path.resolve()}!",
|
|
None,
|
|
_ui_timestamp(),
|
|
)
|
|
continue
|
|
if path == "/api/accept":
|
|
final_plan = _coerce_text(payload.get("final_plan"))
|
|
accept_path = run_dir / "final-plan-accepted.md"
|
|
accept_path.write_text(final_plan, encoding="utf-8")
|
|
final_path = run_dir / "final-plan.md"
|
|
final_path.write_text(final_plan, encoding="utf-8")
|
|
_ui_set_final_plan(ui_state, ui_instance, final_plan, _ui_timestamp())
|
|
_ui_action_result(
|
|
ui_instance,
|
|
"accept",
|
|
"accepted",
|
|
"accepted plan and closing UI",
|
|
None,
|
|
_ui_timestamp(),
|
|
)
|
|
stop_event.set()
|
|
ui_instance.shutdown()
|
|
continue
|
|
if path == "/api/refine":
|
|
if not judge or not plan_template:
|
|
_ui_action_result(ui_instance, "refine", "failed", "refine unavailable", None, _ui_timestamp())
|
|
continue
|
|
context = _coerce_text(payload.get("context")).strip()
|
|
final_plan = _coerce_text(payload.get("final_plan")).strip()
|
|
if not final_plan:
|
|
_ui_action_result(ui_instance, "refine", "failed", "no plan to refine", None, _ui_timestamp())
|
|
continue
|
|
start_ts = _ui_timestamp()
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status="running",
|
|
summary="refining…",
|
|
errors=[],
|
|
timestamp=start_ts,
|
|
)
|
|
task_brief = build_task_brief(task_spec)
|
|
prompt = _build_refine_prompt(plan_template, task_brief, final_plan, context)
|
|
running = spawn_cli_agent(judge, prompt)
|
|
try:
|
|
raw = collect_cli_output(running, args.timeout)
|
|
except TimeoutError as exc:
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status="failed",
|
|
summary=str(exc),
|
|
errors=[str(exc)],
|
|
timestamp=_ui_timestamp(),
|
|
)
|
|
_ui_action_result(ui_instance, "refine", "failed", str(exc), None, _ui_timestamp())
|
|
continue
|
|
normalized = extract_agent_response(judge, raw).strip()
|
|
valid, err = validate_markdown_plan(normalized)
|
|
if not valid:
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status="needs-fix",
|
|
summary=normalized,
|
|
errors=[err] if err else [],
|
|
timestamp=_ui_timestamp(),
|
|
)
|
|
_ui_action_result(ui_instance, "refine", "failed", err or "invalid plan", None, _ui_timestamp())
|
|
continue
|
|
refined_name = f"final-plan-refined-{time.strftime('%Y%m%d-%H%M%S')}.md"
|
|
refined_path = run_dir / refined_name
|
|
refined_path.write_text(normalized, encoding="utf-8")
|
|
final_path = run_dir / "final-plan.md"
|
|
final_path.write_text(normalized, encoding="utf-8")
|
|
_ui_set_final_plan(ui_state, ui_instance, normalized, _ui_timestamp())
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status="complete",
|
|
summary=normalized,
|
|
errors=[],
|
|
timestamp=_ui_timestamp(),
|
|
)
|
|
_ui_action_result(ui_instance, "refine", "complete", "refined plan saved", None, _ui_timestamp())
|
|
continue
|
|
if path == "/api/keepalive":
|
|
keep_open = bool(payload.get("keep_open"))
|
|
if keepalive:
|
|
keepalive.set_keep_open(keep_open)
|
|
deadline = "" if keep_open else _ui_deadline_from_now(DEFAULT_UI_SESSION_TTL_SEC)
|
|
_ui_set_session_state(ui_state, ui_instance, keep_open, deadline, _ui_timestamp())
|
|
status = "enabled" if keep_open else "disabled"
|
|
_ui_action_result(ui_instance, "keepalive", status, f"keep open {status}", None, _ui_timestamp())
|
|
continue
|
|
_ui_action_result(ui_instance, "unknown", "ignored", f"unhandled action: {path}", None, _ui_timestamp())
|
|
except Exception as exc:
|
|
_ui_action_result(ui_instance, "error", "failed", str(exc), None, _ui_timestamp())
|
|
|
|
|
|
def render_planner_prompt(task_spec: Dict[str, Any], plan_template: str, prompt_template: str) -> str:
|
|
brief = build_task_brief(task_spec)
|
|
prompt = prompt_template.replace("{{TASK_BRIEF}}", brief)
|
|
return prompt.replace("{{PLAN_TEMPLATE}}", plan_template)
|
|
|
|
|
|
def render_judge_prompt(task_spec: Dict[str, Any], plans: List[Dict[str, Any]], judge_template: str, prompt_template: str) -> str:
|
|
brief = build_task_brief(task_spec)
|
|
plans_block = "\n\n".join(f"### {p['label']}\n\n{p['plan']}" for p in plans)
|
|
prompt = prompt_template.replace("{{TASK_BRIEF}}", brief)
|
|
prompt = prompt.replace("{{PLANS_MD}}", plans_block)
|
|
return prompt.replace("{{JUDGE_TEMPLATE}}", judge_template)
|
|
|
|
|
|
def load_text(path: str) -> str:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
|
|
|
|
def resolve_path(relative_path: str) -> str:
|
|
base_dir = Path(__file__).resolve().parent
|
|
return str((base_dir / relative_path).resolve())
|
|
|
|
|
|
def get_run_root() -> Path:
|
|
return Path.cwd() / "llm-council" / "runs"
|
|
|
|
|
|
def slugify(value: str, max_len: int = 40) -> str:
|
|
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
|
|
if not cleaned:
|
|
return "run"
|
|
return cleaned[:max_len].strip("-")
|
|
|
|
|
|
def unique_run_dir(run_root: Path, base_name: str) -> Path:
|
|
candidate = run_root / base_name
|
|
if not candidate.exists():
|
|
return candidate
|
|
counter = 2
|
|
while True:
|
|
candidate = run_root / f"{base_name}-{counter}"
|
|
if not candidate.exists():
|
|
return candidate
|
|
counter += 1
|
|
|
|
|
|
def maybe_trash_empty_dir(path: Path) -> None:
|
|
if not path.exists() or not path.is_dir():
|
|
return
|
|
if any(path.iterdir()):
|
|
return
|
|
trash_bin = shutil.which("trash")
|
|
if not trash_bin:
|
|
return
|
|
subprocess.run([trash_bin, str(path)], check=False)
|
|
|
|
|
|
def get_default_config_path() -> Path:
|
|
base = os.environ.get("XDG_CONFIG_HOME")
|
|
if base:
|
|
return Path(base) / "llm-council" / "agents.json"
|
|
return Path.home() / ".config" / "llm-council" / "agents.json"
|
|
|
|
|
|
def load_agent_config_file(path: Path) -> Optional[Dict[str, Any]]:
|
|
if not path.exists():
|
|
return None
|
|
data = load_json(str(path))
|
|
if isinstance(data, dict) and "agents" in data and isinstance(data["agents"], dict):
|
|
return data["agents"]
|
|
if isinstance(data, dict) and ("planners" in data or "judge" in data):
|
|
return data
|
|
return None
|
|
|
|
|
|
def configure_agents(config_path: Path) -> None:
|
|
def prompt_text(label: str, default: Optional[str] = None) -> str:
|
|
suffix = f" (default: {default})" if default else ""
|
|
value = input(f"{label}{suffix}: ").strip()
|
|
return value if value else (default or "")
|
|
|
|
def prompt_choice(label: str, choices: List[str], default_idx: int = 1) -> int:
|
|
while True:
|
|
raw = input(f"{label} (default: {default_idx}): ").strip()
|
|
if not raw:
|
|
return default_idx
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
print("Please enter a number.")
|
|
continue
|
|
if 1 <= value <= len(choices):
|
|
return value
|
|
print(f"Choose a number between 1 and {len(choices)}.")
|
|
|
|
def prompt_yes_no(label: str, default_yes: bool = True) -> bool:
|
|
default = "Y/n" if default_yes else "y/N"
|
|
raw = input(f"{label} [{default}] ").strip().lower()
|
|
if not raw:
|
|
return default_yes
|
|
return raw in ("y", "yes")
|
|
|
|
print("Council setup")
|
|
if prompt_yes_no("Use default council (Codex CLI + Claude CLI + Gemini CLI)?", default_yes=True):
|
|
planners = [
|
|
{"name": "codex-1", "kind": "codex", "model": CODEX_MODEL, "reasoning_effort": CODEX_REASONING},
|
|
{"name": "claude-2", "kind": "claude", "model": CLAUDE_MODEL},
|
|
{"name": "gemini-3", "kind": "gemini", "model": GEMINI_MODEL},
|
|
]
|
|
judge = planners[0]
|
|
else:
|
|
count_raw = prompt_text("How many planners?", "3")
|
|
try:
|
|
planner_count = max(1, int(count_raw))
|
|
except ValueError:
|
|
planner_count = 3
|
|
|
|
planners = []
|
|
for idx in range(1, planner_count + 1):
|
|
print(f"\nPlanner {idx}")
|
|
kinds = ["codex", "claude", "gemini", "opencode", "custom"]
|
|
for i, kind in enumerate(kinds, start=1):
|
|
print(f"{i}) {kind}")
|
|
choice = prompt_choice("Choose CLI", kinds, default_idx=1)
|
|
kind = kinds[choice - 1]
|
|
|
|
default_name = f"{kind}-{idx}"
|
|
name = prompt_text("Planner name", default_name) or default_name
|
|
|
|
planner: Dict[str, Any] = {"name": name, "kind": kind}
|
|
if kind == "codex":
|
|
planner["model"] = prompt_text("Codex model", CODEX_MODEL)
|
|
planner["reasoning_effort"] = prompt_text("Reasoning effort", CODEX_REASONING)
|
|
elif kind == "claude":
|
|
planner["model"] = prompt_text("Claude model", CLAUDE_MODEL)
|
|
elif kind == "gemini":
|
|
planner["model"] = prompt_text("Gemini model", GEMINI_MODEL)
|
|
elif kind == "opencode":
|
|
print(
|
|
"Opencode provider/model (note: run 'opencode models' in another terminal to see available models)"
|
|
)
|
|
model = prompt_text("Provider/model", "")
|
|
while not model:
|
|
model = prompt_text("Provider/model", "")
|
|
planner["model"] = model
|
|
else:
|
|
planner["command"] = prompt_text("Command", "")
|
|
while not planner["command"]:
|
|
planner["command"] = prompt_text("Command", "")
|
|
prompt_mode = prompt_text("Prompt mode (arg|stdin)", "arg").lower()
|
|
planner["prompt_mode"] = "stdin" if prompt_mode == "stdin" else "arg"
|
|
|
|
planners.append(planner)
|
|
|
|
print("\nWhich model should be the judge?")
|
|
for i, planner in enumerate(planners, start=1):
|
|
model = planner.get("model")
|
|
label = f"{planner['name']} ({planner['kind']}"
|
|
if model:
|
|
label += f": {model}"
|
|
label += ")"
|
|
print(f"{i}) {label}")
|
|
judge_idx = prompt_choice("Select judge", planners, default_idx=1)
|
|
judge = planners[judge_idx - 1]
|
|
|
|
payload = {"planners": planners, "judge": judge}
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
|
print(f"\nSaving config to {config_path}.")
|
|
write_json(str(tmp_path), payload)
|
|
os.replace(tmp_path, config_path)
|
|
print("Saved.")
|
|
|
|
|
|
def build_task_brief(task_spec: Dict[str, Any]) -> str:
|
|
lines = []
|
|
task = (task_spec.get("task") or "").strip()
|
|
lines.append(f"Task: {task}")
|
|
constraints = task_spec.get("constraints") or []
|
|
if constraints:
|
|
lines.append("Constraints:")
|
|
for item in constraints:
|
|
lines.append(f"- {item}")
|
|
repo = task_spec.get("repo_context") or {}
|
|
if repo:
|
|
root = repo.get("root")
|
|
paths = repo.get("paths") or []
|
|
notes = repo.get("notes")
|
|
if root:
|
|
lines.append(f"Repo root: {root}")
|
|
if paths:
|
|
lines.append("Relevant paths:")
|
|
for path in paths:
|
|
lines.append(f"- {path}")
|
|
if notes:
|
|
lines.append(f"Notes: {notes}")
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def _normalize_agent_spec(spec: Any, fallback_name: str) -> AgentConfig:
|
|
if isinstance(spec, str):
|
|
data = {"name": spec, "kind": spec}
|
|
elif isinstance(spec, dict):
|
|
data = spec
|
|
else:
|
|
raise ValueError("agent spec must be an object or string")
|
|
name = str(data.get("name") or fallback_name).strip()
|
|
kind = str(data.get("kind") or data.get("cli") or data.get("type") or name).strip().lower()
|
|
output_format = str(data.get("output_format") or "text").strip()
|
|
model = data.get("model")
|
|
reasoning_effort = data.get("reasoning_effort") or data.get("reasoning")
|
|
agent = data.get("agent")
|
|
attach = data.get("attach")
|
|
cli_format = data.get("format") or data.get("cli_format")
|
|
command = data.get("command")
|
|
prompt_mode = data.get("prompt_mode") or "arg"
|
|
extra_args = data.get("extra_args") or []
|
|
if not isinstance(extra_args, list):
|
|
extra_args = [str(extra_args)]
|
|
extra_args = [str(item) for item in extra_args]
|
|
if kind == "opencode" and not cli_format:
|
|
cli_format = "json"
|
|
return AgentConfig(
|
|
name=name,
|
|
kind=kind,
|
|
command=command,
|
|
output_format=output_format,
|
|
model=model,
|
|
reasoning_effort=reasoning_effort,
|
|
agent=agent,
|
|
attach=attach,
|
|
cli_format=cli_format,
|
|
prompt_mode=prompt_mode,
|
|
extra_args=extra_args,
|
|
)
|
|
|
|
|
|
def load_agent_configs(task_spec: Dict[str, Any], config_path: Optional[Path] = None) -> Tuple[List[AgentConfig], AgentConfig]:
|
|
agents_spec = task_spec.get("agents")
|
|
if not agents_spec:
|
|
config_path = config_path or get_default_config_path()
|
|
config_spec = load_agent_config_file(config_path)
|
|
if config_spec:
|
|
agents_spec = config_spec
|
|
|
|
if not agents_spec:
|
|
raise ValueError(
|
|
"Uh oh! Your models are not configured. Please run `./setup.sh` to select your models. "
|
|
"You can override or change these models at any time by running the setup script again."
|
|
)
|
|
|
|
if isinstance(agents_spec, list):
|
|
planner_specs = agents_spec
|
|
judge_spec = None
|
|
elif isinstance(agents_spec, dict):
|
|
planner_specs = agents_spec.get("planners") or agents_spec.get("agents") or []
|
|
judge_spec = agents_spec.get("judge")
|
|
else:
|
|
raise ValueError("agents must be a list or object with planners")
|
|
|
|
if not planner_specs:
|
|
raise ValueError("agents.planners must include at least one agent")
|
|
|
|
planners: List[AgentConfig] = []
|
|
seen = set()
|
|
for idx, spec in enumerate(planner_specs, start=1):
|
|
agent = _normalize_agent_spec(spec, f"planner-{idx}")
|
|
if agent.name in seen:
|
|
agent.name = f"{agent.name}-{idx}"
|
|
seen.add(agent.name)
|
|
planners.append(agent)
|
|
|
|
if judge_spec:
|
|
judge = _normalize_agent_spec(judge_spec, "judge")
|
|
else:
|
|
primary = planners[0]
|
|
judge = AgentConfig(
|
|
name=f"{primary.name}-judge",
|
|
kind=primary.kind,
|
|
command=primary.command,
|
|
output_format=primary.output_format,
|
|
model=primary.model,
|
|
reasoning_effort=primary.reasoning_effort,
|
|
agent=primary.agent,
|
|
attach=primary.attach,
|
|
cli_format=primary.cli_format,
|
|
prompt_mode=primary.prompt_mode,
|
|
extra_args=list(primary.extra_args),
|
|
)
|
|
|
|
return planners, judge
|
|
|
|
|
|
def run_planners(
|
|
task_spec: Dict[str, Any],
|
|
planners: List[AgentConfig],
|
|
planner_prompt_template: str,
|
|
plan_template: str,
|
|
timeout_sec: int,
|
|
run_dir: str,
|
|
ui_state: Optional["ui_server.UIState"] = None,
|
|
ui_instance: Optional["ui_server.UIServer"] = None,
|
|
) -> List[AgentResult]:
|
|
results: List[AgentResult] = []
|
|
remaining = planners[:]
|
|
attempt = 0
|
|
while remaining and attempt <= RETRY_LIMIT:
|
|
running: List[RunningAgent] = []
|
|
for planner in remaining:
|
|
prompt = render_planner_prompt(task_spec, plan_template, planner_prompt_template)
|
|
timestamp = _ui_timestamp()
|
|
_ui_upsert_planner(
|
|
ui_state,
|
|
ui_instance,
|
|
planner_id=planner.name,
|
|
status="running",
|
|
summary="starting…",
|
|
errors=[],
|
|
timestamp=timestamp,
|
|
)
|
|
running.append(spawn_cli_agent(planner, prompt))
|
|
|
|
remaining = []
|
|
for entry in running:
|
|
try:
|
|
raw = collect_cli_output(entry, timeout_sec)
|
|
timeout_error = None
|
|
except TimeoutError as exc:
|
|
raw = ""
|
|
timeout_error = str(exc)
|
|
normalized = extract_agent_response(entry.config, raw)
|
|
plan_text = normalized.strip()
|
|
if timeout_error is not None:
|
|
valid, err = False, timeout_error
|
|
else:
|
|
valid, err = validate_markdown_plan(plan_text)
|
|
plan_path = Path(run_dir) / f"plan-{entry.config.name}-attempt{attempt + 1}.md"
|
|
write_attempt = attempt > 0 or not valid
|
|
if write_attempt:
|
|
plan_path.write_text(plan_text, encoding="utf-8")
|
|
if valid:
|
|
final_path = Path(run_dir) / f"plan-{entry.config.name}.md"
|
|
final_path.write_text(plan_text, encoding="utf-8")
|
|
timestamp = _ui_timestamp()
|
|
status = "complete" if valid else ("failed" if timeout_error else "needs-fix")
|
|
errors = [err] if err else []
|
|
summary = plan_text
|
|
_ui_upsert_planner(
|
|
ui_state,
|
|
ui_instance,
|
|
planner_id=entry.config.name,
|
|
status=status,
|
|
summary=summary or ("error" if errors else ""),
|
|
errors=errors,
|
|
timestamp=timestamp,
|
|
)
|
|
result = AgentResult(
|
|
name=entry.config.name,
|
|
raw_output=raw,
|
|
data={"path": str(plan_path if write_attempt else final_path), "text": plan_text},
|
|
valid=valid,
|
|
error=err,
|
|
)
|
|
results.append(result)
|
|
if not valid and attempt < RETRY_LIMIT:
|
|
retry_timestamp = _ui_timestamp()
|
|
_ui_upsert_planner(
|
|
ui_state,
|
|
ui_instance,
|
|
planner_id=entry.config.name,
|
|
status="retrying",
|
|
summary="retry scheduled",
|
|
errors=[err] if err else [],
|
|
timestamp=retry_timestamp,
|
|
)
|
|
remaining.append(entry.config)
|
|
|
|
attempt += 1
|
|
return results
|
|
|
|
|
|
def run_judge(
|
|
task_spec: Dict[str, Any],
|
|
plans: List[Dict[str, Any]],
|
|
judge: AgentConfig,
|
|
judge_prompt_template: str,
|
|
judge_template: str,
|
|
timeout_sec: int,
|
|
run_dir: str,
|
|
ui_state: Optional["ui_server.UIState"] = None,
|
|
ui_instance: Optional["ui_server.UIServer"] = None,
|
|
) -> AgentResult:
|
|
prompt = render_judge_prompt(task_spec, plans, judge_template, judge_prompt_template)
|
|
start_timestamp = _ui_timestamp()
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status="running",
|
|
summary="starting…",
|
|
errors=[],
|
|
timestamp=start_timestamp,
|
|
)
|
|
running = spawn_cli_agent(judge, prompt)
|
|
try:
|
|
raw = collect_cli_output(running, timeout_sec)
|
|
timeout_error = None
|
|
except TimeoutError as exc:
|
|
raw = ""
|
|
timeout_error = str(exc)
|
|
normalized = extract_agent_response(judge, raw)
|
|
judge_text = normalized.strip()
|
|
judge_path = Path(run_dir) / "judge.md"
|
|
judge_path.write_text(judge_text, encoding="utf-8")
|
|
if timeout_error is not None:
|
|
valid, err = False, timeout_error
|
|
else:
|
|
valid, err = validate_markdown_judge(judge_text)
|
|
finish_timestamp = _ui_timestamp()
|
|
status = "complete" if valid else ("failed" if timeout_error else "needs-fix")
|
|
errors = [err] if err else []
|
|
summary = judge_text
|
|
_ui_update_judge(
|
|
ui_state,
|
|
ui_instance,
|
|
status=status,
|
|
summary=summary or ("error" if errors else ""),
|
|
errors=errors,
|
|
timestamp=finish_timestamp,
|
|
)
|
|
return AgentResult(
|
|
name=judge.name,
|
|
raw_output=raw,
|
|
data={"path": str(judge_path), "text": judge_text},
|
|
valid=valid,
|
|
error=err,
|
|
)
|
|
|
|
|
|
def extract_final_plan(judge_text: str) -> str:
|
|
marker = "## Final Plan"
|
|
if marker not in judge_text:
|
|
return judge_text
|
|
after = judge_text.split(marker, 1)[1]
|
|
plan_start = after.find("# Plan")
|
|
if plan_start == -1:
|
|
return after.strip()
|
|
return after[plan_start:].strip()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(prog="llm-council")
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
run = sub.add_parser("run")
|
|
run.add_argument("--spec", required=True, help="Path to task spec JSON")
|
|
run.add_argument("--out", required=False, help="Path to write final plan Markdown")
|
|
run.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SEC)
|
|
run.add_argument("--seed", type=int, default=None)
|
|
run.add_argument("--config", required=False, help="Path to agents config JSON")
|
|
run.add_argument("--no-ui", action="store_true", help="Disable the live UI server")
|
|
run.add_argument(
|
|
"--ui-keepalive-seconds",
|
|
type=int,
|
|
default=DEFAULT_UI_KEEPALIVE_SEC,
|
|
help="Keep the UI server alive for N seconds after completion (0 to disable)",
|
|
)
|
|
|
|
ui = sub.add_parser("ui")
|
|
ui.add_argument("--run-dir", required=True, help="Path to a run directory to resume")
|
|
ui.add_argument("--no-open", action="store_true", help="Do not auto-open a browser window")
|
|
|
|
configure = sub.add_parser("configure")
|
|
configure.add_argument("--config", required=False, help="Path to write agents config JSON")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.cmd == "configure":
|
|
config_path = Path(args.config) if args.config else get_default_config_path()
|
|
configure_agents(config_path)
|
|
return 0
|
|
if args.cmd == "ui":
|
|
run_dir = Path(args.run_dir).expanduser().resolve()
|
|
snapshot_path = run_dir / "ui-state.json"
|
|
initial_state: Dict[str, Any] = _rebuild_ui_state_from_run(run_dir)
|
|
ui_state = ui_server.UIState(initial_state, snapshot_path=snapshot_path)
|
|
ui_instance = ui_server.start_server(state=ui_state)
|
|
ui_url = ui_instance.ui_url
|
|
action_stop = threading.Event()
|
|
keepalive = _KeepaliveController()
|
|
action_thread = threading.Thread(
|
|
target=_handle_ui_actions,
|
|
args=(
|
|
ui_instance,
|
|
ui_state,
|
|
run_dir,
|
|
{},
|
|
argparse.Namespace(timeout=DEFAULT_TIMEOUT_SEC),
|
|
get_default_config_path(),
|
|
action_stop,
|
|
keepalive,
|
|
None,
|
|
None,
|
|
),
|
|
name="ui-action-handler",
|
|
daemon=True,
|
|
)
|
|
action_thread.start()
|
|
_start_ui_session_timer(ui_instance, ui_state, action_stop, keepalive)
|
|
if not args.no_open:
|
|
webbrowser.open(ui_url)
|
|
print(f"UI server running at {ui_url}")
|
|
try:
|
|
while not action_stop.is_set():
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
ui_instance.shutdown()
|
|
return 0
|
|
|
|
try:
|
|
task_spec = load_json(args.spec)
|
|
except FileNotFoundError:
|
|
print(
|
|
f"Spec file not found: {args.spec}\n"
|
|
"Uh oh! Your models are not configured. Please run `./setup.sh` to select your models. "
|
|
"You can override or change these models at any time by running the setup script again.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
config_path = Path(args.config) if args.config else get_default_config_path()
|
|
prompt_text = load_text(resolve_path("../references/prompts.md"))
|
|
planner_prompt = prompt_text.split("## Judge Prompt")[0].split("```text", 1)[1].rsplit("```", 1)[0]
|
|
judge_prompt = prompt_text.split("## Judge Prompt", 1)[1].split("```text", 1)[1].rsplit("```", 1)[0]
|
|
|
|
plan_template = load_text(resolve_path("../references/templates/plan.md"))
|
|
judge_template = load_text(resolve_path("../references/templates/judge.md"))
|
|
|
|
run_root = get_run_root()
|
|
run_root.mkdir(parents=True, exist_ok=True)
|
|
base_label = task_spec.get("run_id") or task_spec.get("run_label")
|
|
if not base_label:
|
|
task_label = slugify(task_spec.get("task") or "run")
|
|
base_label = f"{time.strftime('%Y%m%d')}-{task_label}"
|
|
run_dir = unique_run_dir(run_root, base_label)
|
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
ui_state: Optional[ui_server.UIState] = None
|
|
ui_instance: Optional[ui_server.UIServer] = None
|
|
keepalive = _KeepaliveController() if not args.no_ui else None
|
|
if not args.no_ui:
|
|
snapshot_path = run_dir / "ui-state.json"
|
|
ui_state = ui_server.UIState(snapshot_path=snapshot_path)
|
|
ui_instance = ui_server.start_server(state=ui_state)
|
|
ui_url = ui_instance.ui_url
|
|
webbrowser.open(ui_url)
|
|
print(f"UI server running at {ui_url}")
|
|
|
|
try:
|
|
planners, judge = load_agent_configs(task_spec, config_path=config_path)
|
|
except ValueError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
|
|
if args.seed is not None:
|
|
random.seed(args.seed)
|
|
|
|
if ui_state:
|
|
timestamp = _ui_timestamp()
|
|
initial_planners = [
|
|
{"id": planner.name, "status": "pending", "summary": "", "errors": []} for planner in planners
|
|
]
|
|
initial_state = {
|
|
"run_id": run_dir.name,
|
|
"task_brief": build_task_brief(task_spec),
|
|
"phase": "starting",
|
|
"planners": initial_planners,
|
|
"judge": {"status": "pending", "summary": "", "errors": []},
|
|
"final_plan": "",
|
|
"errors": [],
|
|
"keep_open": False,
|
|
"ui_deadline": _ui_deadline_from_now(DEFAULT_UI_SESSION_TTL_SEC),
|
|
"timestamps": {"started_at": timestamp, "updated_at": timestamp},
|
|
}
|
|
ui_state.set(initial_state)
|
|
_ui_emit(ui_instance, "phase_change", {"phase": "starting", "timestamp": timestamp})
|
|
|
|
if ui_instance:
|
|
action_stop = threading.Event()
|
|
action_thread = threading.Thread(
|
|
target=_handle_ui_actions,
|
|
args=(
|
|
ui_instance,
|
|
ui_state,
|
|
run_dir,
|
|
task_spec,
|
|
args,
|
|
config_path,
|
|
action_stop,
|
|
keepalive,
|
|
judge,
|
|
plan_template,
|
|
),
|
|
name="ui-action-handler",
|
|
daemon=True,
|
|
)
|
|
action_thread.start()
|
|
_start_ui_session_timer(ui_instance, ui_state, action_stop, keepalive)
|
|
|
|
_ui_set_phase(ui_state, ui_instance, "planning", _ui_timestamp())
|
|
planner_results = run_planners(
|
|
task_spec,
|
|
planners,
|
|
planner_prompt,
|
|
plan_template,
|
|
args.timeout,
|
|
str(run_dir),
|
|
ui_state=ui_state,
|
|
ui_instance=ui_instance,
|
|
)
|
|
latest_valid: Dict[str, Dict[str, Any]] = {}
|
|
for result in planner_results:
|
|
if result.valid and result.data:
|
|
latest_valid[result.name] = result.data
|
|
valid_plans = list(latest_valid.values())
|
|
|
|
_ui_set_phase(ui_state, ui_instance, "judging", _ui_timestamp())
|
|
randomized_plans = []
|
|
for idx, plan in enumerate(valid_plans):
|
|
labeled = {"label": f"Plan {idx + 1}", "plan": anonymize_text(plan["text"])}
|
|
randomized_plans.append(labeled)
|
|
random.shuffle(randomized_plans)
|
|
|
|
judge_result = run_judge(
|
|
task_spec,
|
|
randomized_plans,
|
|
judge,
|
|
judge_prompt,
|
|
judge_template,
|
|
args.timeout,
|
|
str(run_dir),
|
|
ui_state=ui_state,
|
|
ui_instance=ui_instance,
|
|
)
|
|
metadata = {
|
|
"used_plans": [p["label"] for p in randomized_plans],
|
|
"agents": {
|
|
"planners": [planner.name for planner in planners],
|
|
"judge": judge.name,
|
|
},
|
|
"validation": {
|
|
"task_spec_valid": True,
|
|
"plans_valid": {r.name: r.valid for r in planner_results},
|
|
"judge_valid": judge_result.valid,
|
|
},
|
|
"warnings": [r.error for r in planner_results if r.error],
|
|
}
|
|
|
|
_ui_set_phase(ui_state, ui_instance, "finalizing", _ui_timestamp())
|
|
final_text = extract_final_plan(judge_result.data.get("text", "") if judge_result.data else "")
|
|
final_path = run_dir / "final-plan.md"
|
|
final_path.write_text(final_text, encoding="utf-8")
|
|
_ui_set_final_plan(ui_state, ui_instance, final_text, _ui_timestamp())
|
|
_ui_set_phase(ui_state, ui_instance, "complete", _ui_timestamp())
|
|
|
|
if args.out:
|
|
Path(args.out).write_text(final_text, encoding="utf-8")
|
|
else:
|
|
print(final_text)
|
|
|
|
if ui_instance:
|
|
resume_cmd = f"python scripts/llm_council.py ui --run-dir {run_dir}"
|
|
print(f"Resume UI: {resume_cmd}")
|
|
if ui_instance and args.ui_keepalive_seconds > 0:
|
|
print(f"Keeping UI server alive for {args.ui_keepalive_seconds}s unless kept open...")
|
|
start = time.time()
|
|
while True:
|
|
if keepalive and keepalive.should_keep_open():
|
|
time.sleep(1)
|
|
continue
|
|
if time.time() - start >= args.ui_keepalive_seconds:
|
|
break
|
|
time.sleep(1)
|
|
ui_instance.shutdown()
|
|
|
|
maybe_trash_empty_dir(run_dir)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|