roundtable-cli 0.4.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.
roundtable/mcp.py ADDED
@@ -0,0 +1,248 @@
1
+ """MCP server that exposes roundtable operations as callable tools.
2
+
3
+ Typical workflow an LLM should follow:
4
+ 1. roundtable_init — set up .roundtable/ if this is a fresh project
5
+ 2. roundtable_plan — generate a plan from a goal string
6
+ 3. roundtable_approve — mark the plan approved (required before run)
7
+ 4. roundtable_run — start the run (non-blocking; returns immediately)
8
+ 5. roundtable_status — poll until status == "done" or "failed"
9
+
10
+ Optionally call roundtable_map first to generate ARCHITECTURE.md + PRD.md from
11
+ an existing codebase before planning.
12
+
13
+ Entry point (stdio, for Claude Code / Claude Desktop):
14
+ roundtable mcp
15
+ roundtable-mcp
16
+
17
+ Register in Claude Code .claude/settings.json:
18
+ {
19
+ "mcpServers": {
20
+ "roundtable": { "command": "roundtable-mcp" }
21
+ }
22
+ }
23
+
24
+ Requires: pip install 'roundtable-cli[mcp]'
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+
36
+ def _build_server() -> Any:
37
+ try:
38
+ from mcp.server.fastmcp import FastMCP # type: ignore[import]
39
+ except ImportError:
40
+ print(
41
+ "error: 'mcp' package not installed — run: pip install 'roundtable-cli[mcp]'",
42
+ file=sys.stderr,
43
+ )
44
+ sys.exit(1)
45
+
46
+ mcp = FastMCP(
47
+ "roundtable",
48
+ instructions=(
49
+ "Roundtable orchestrates multi-agent LLM tasks. "
50
+ "Workflow: roundtable_init (once) → roundtable_plan → roundtable_approve → roundtable_run. "
51
+ "roundtable_run is non-blocking; poll roundtable_status until status is 'done' or 'failed'. "
52
+ "All tools accept a `project` argument (path to the project directory; defaults to '.')."
53
+ ),
54
+ )
55
+
56
+ # ------------------------------------------------------------------ #
57
+ # Helpers
58
+ # ------------------------------------------------------------------ #
59
+
60
+ def _cli(*args: str) -> str:
61
+ r = subprocess.run(
62
+ ["roundtable", *args],
63
+ capture_output=True, text=True,
64
+ )
65
+ out = (r.stdout or "").strip()
66
+ err = (r.stderr or "").strip()
67
+ if r.returncode not in (0,):
68
+ return f"error (exit {r.returncode}): {err or out or '(no output)'}"
69
+ return out or err or "ok"
70
+
71
+ def _read_state(project: str) -> dict[str, Any]:
72
+ from .store import Store
73
+ from .insights import build_state
74
+ return build_state(Store(Path(project).resolve()))
75
+
76
+ # ------------------------------------------------------------------ #
77
+ # Tools
78
+ # ------------------------------------------------------------------ #
79
+
80
+ @mcp.tool()
81
+ def roundtable_init(directory: str = ".") -> str:
82
+ """Initialize a .roundtable/ directory and default config in the given project directory."""
83
+ return _cli("init", directory, "--no-models")
84
+
85
+ @mcp.tool()
86
+ def roundtable_map(
87
+ target: str = ".",
88
+ project: str = ".",
89
+ model: str | None = None,
90
+ ) -> str:
91
+ """Scan a codebase and generate ARCHITECTURE.md and PRD.md under .roundtable/docs/.
92
+
93
+ Use this before roundtable_plan when you want to base the plan on an
94
+ existing codebase rather than a bare goal string.
95
+
96
+ Args:
97
+ target: Path to the codebase to scan (default: project root).
98
+ project: Roundtable project root where .roundtable/ lives (default: '.').
99
+ model: Override the analyst agent/model (e.g. 'claude:opus').
100
+ """
101
+ args = ["map", "--project", project, "--target", target]
102
+ if model:
103
+ args += ["--model", model]
104
+ return _cli(*args)
105
+
106
+ @mcp.tool()
107
+ def roundtable_plan(
108
+ goal: str,
109
+ project: str = ".",
110
+ model: str | None = None,
111
+ ) -> str:
112
+ """Generate a multi-agent execution plan from a goal string.
113
+
114
+ Writes plan.json and PLAN.md under .roundtable/plan/. The plan must be
115
+ approved with roundtable_approve before it can be run.
116
+
117
+ Args:
118
+ goal: Natural-language description of what the agents should build or do.
119
+ project: Roundtable project root (default: '.').
120
+ model: Override the planner agent/model (e.g. 'claude:opus').
121
+ """
122
+ args = ["plan", "--goal", goal, "--project", project]
123
+ if model:
124
+ args += ["--model", model]
125
+ return _cli(*args)
126
+
127
+ @mcp.tool()
128
+ def roundtable_approve(project: str = ".") -> str:
129
+ """Approve the current plan so it can be executed by roundtable_run.
130
+
131
+ Args:
132
+ project: Roundtable project root (default: '.').
133
+ """
134
+ return _cli("approve", "--project", project)
135
+
136
+ @mcp.tool()
137
+ def roundtable_run(project: str = ".", approve: bool = False) -> str:
138
+ """Start executing the approved plan in the background (non-blocking).
139
+
140
+ Returns immediately after starting the run process. Use roundtable_status
141
+ to monitor progress. The run continues even if this MCP server exits.
142
+
143
+ Args:
144
+ project: Roundtable project root (default: '.').
145
+ approve: If True, auto-approve the plan before running (combines
146
+ roundtable_approve + roundtable_run in one call).
147
+ """
148
+ from . import runctl
149
+ from .store import Store
150
+
151
+ store = Store(Path(project).resolve())
152
+ pid, msg = runctl.start_run(store, approve=approve)
153
+ if pid is None:
154
+ return f"error: {msg}. Call roundtable_status to monitor, or roundtable_stop to cancel."
155
+ return (
156
+ f"Run started (pid={pid}). "
157
+ "Agents are now executing tasks in the background. "
158
+ "Call roundtable_status() to monitor progress."
159
+ )
160
+
161
+ @mcp.tool()
162
+ def roundtable_stop(project: str = ".") -> str:
163
+ """Stop a running roundtable execution.
164
+
165
+ Reads the PID from the run.pid file and sends SIGTERM to gracefully
166
+ stop the process group when possible.
167
+
168
+ Args:
169
+ project: Roundtable project root (default: '.').
170
+ """
171
+ from . import runctl
172
+ from .store import Store
173
+
174
+ store = Store(Path(project).resolve())
175
+ _, msg = runctl.stop_run(store)
176
+ return msg
177
+
178
+ @mcp.tool()
179
+ def roundtable_status(project: str = ".") -> str:
180
+ """Return the current run state as JSON.
181
+
182
+ Includes: overall status, per-phase/task progress, active tasks,
183
+ per-agent stats, timing, and recent events.
184
+
185
+ Args:
186
+ project: Roundtable project root (default: '.').
187
+ """
188
+ state = _read_state(project)
189
+ return json.dumps(state, indent=2)
190
+
191
+ # ------------------------------------------------------------------ #
192
+ # Resources (read-only context; always operates on cwd / project='.')
193
+ @mcp.tool()
194
+ def roundtable_usage(project: str = ".") -> str:
195
+ """Return aggregated provider usage stats (calls, tokens, duration).
196
+
197
+ Reads 'usage' events from the run log and returns a summary. Only
198
+ available after a run completes or partially completes.
199
+
200
+ Args:
201
+ project: Roundtable project root (default: '.').
202
+ """
203
+ from .store import Store
204
+
205
+ store = Store(Path(project).resolve())
206
+ events = store.read_events()
207
+ usage_events = [e for e in events if e.get("type") == "usage"]
208
+ if not usage_events:
209
+ return json.dumps({"message": "no usage data recorded yet"})
210
+ # Return the most recent usage snapshot.
211
+ latest = usage_events[-1]
212
+ return json.dumps({
213
+ k: v for k, v in latest.items()
214
+ if k not in ("type", "ts", "msg")
215
+ }, indent=2)
216
+
217
+ # ------------------------------------------------------------------ #
218
+
219
+ @mcp.resource("roundtable://plan")
220
+ def resource_plan() -> str:
221
+ """The current plan.json — phases, tasks, runners, and approval state."""
222
+ from .store import Store
223
+ store = Store(Path(".").resolve())
224
+ p = store.manifest_path
225
+ if not p.exists():
226
+ return json.dumps({"error": "no plan found — call roundtable_plan first"})
227
+ return p.read_text()
228
+
229
+ @mcp.resource("roundtable://state")
230
+ def resource_state() -> str:
231
+ """Live run state snapshot (same data as roundtable_status tool, project='.')."""
232
+ return json.dumps(_read_state("."), indent=2)
233
+
234
+ @mcp.resource("roundtable://logs")
235
+ def resource_logs() -> str:
236
+ """Last 50 run events from run.log as a JSON array (newest first)."""
237
+ from .store import Store
238
+ store = Store(Path(".").resolve())
239
+ events = store.read_events()
240
+ return json.dumps(list(reversed(events[-50:])), indent=2)
241
+
242
+ return mcp
243
+
244
+
245
+ def run_server() -> None:
246
+ """Entry point: start the roundtable MCP server over stdio."""
247
+ server = _build_server()
248
+ server.run()
@@ -0,0 +1,309 @@
1
+ """Interactive per-role model selection for `roundtable models` and `roundtable init`.
2
+
3
+ Lists the models actually connected to the configured backend and lets you pick one
4
+ per role (planner / main / phase / task) with a two-step *provider -> model* prompt,
5
+ then writes the choices into ``roundtable.config.yaml`` (preserving the rest of the
6
+ file). Works for two backends:
7
+
8
+ * ``pi`` — the pi-family tool reports its own catalog: ``omp models --json`` (rich)
9
+ or ``pi --list-models`` (best-effort text). "provider" is the model's
10
+ provider (anthropic, opencode-go, ...); the chosen ref is ``{model: sel}``.
11
+ * ``cli`` — each configured agent's ``models_command`` (via :mod:`.discovery`).
12
+ "provider" is the agent (claude, opencode, ...); the ref is
13
+ ``{agent, model}``.
14
+
15
+ Listing/parsing are pure functions; the picker takes injectable ``input_fn``/``out``
16
+ so it can be driven from tests without a real terminal.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Callable
29
+
30
+ from .config import CONFIG_FILENAME, Config
31
+ from .discovery import AgentStatus, discover
32
+ from .models import AgentRef
33
+
34
+ ROLES = ["planner", "main", "phase", "task"]
35
+
36
+
37
+ @dataclass
38
+ class ModelChoice:
39
+ label: str # human-readable line shown in the picker
40
+ ref: AgentRef # what gets written to the config for this choice
41
+
42
+
43
+ @dataclass
44
+ class ModelGroup:
45
+ name: str # provider (pi backend) or agent (cli backend)
46
+ installed: bool = True
47
+ note: str = "" # why there are no choices, if empty
48
+ choices: list[ModelChoice] = field(default_factory=list)
49
+
50
+
51
+ # --------------------------------------------------------------------------- #
52
+ # Listing / grouping (pure where possible)
53
+ # --------------------------------------------------------------------------- #
54
+ def list_model_groups(config: Config, *, timeout: float = 20.0) -> list[ModelGroup]:
55
+ """Model groups for the configured backend (empty for litellm/scripted)."""
56
+ if config.provider == "pi":
57
+ return _pi_groups(config, timeout)
58
+ if config.provider == "cli":
59
+ return _cli_groups(config, timeout)
60
+ return []
61
+
62
+
63
+ def _pi_groups(config: Config, timeout: float) -> list[ModelGroup]:
64
+ flavor = (config.pi.flavor or "pi").lower()
65
+ cmd = list(config.pi.command) or (["omp"] if flavor == "omp" else ["pi"])
66
+ if shutil.which(cmd[0]) is None:
67
+ return [ModelGroup(name=cmd[0], installed=False, note="not on PATH")]
68
+ argv = cmd + (["models", "--json"] if flavor == "omp" else ["--list-models"])
69
+ try:
70
+ res = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
71
+ except (subprocess.TimeoutExpired, OSError) as e: # pragma: no cover - env-specific
72
+ return [ModelGroup(name=cmd[0], installed=False, note=f"model query failed: {e}")]
73
+ if res.returncode != 0:
74
+ tail = (res.stderr or res.stdout).strip().splitlines()
75
+ return [ModelGroup(name=cmd[0], installed=False,
76
+ note=f"exited {res.returncode}" + (f": {tail[-1][:80]}" if tail else ""))]
77
+ if flavor == "omp":
78
+ try:
79
+ data = json.loads(res.stdout)
80
+ except json.JSONDecodeError:
81
+ return [ModelGroup(name="omp", installed=False, note="`omp models --json` returned non-JSON")]
82
+ return omp_groups_from_json(data)
83
+ return pi_groups_from_text(res.stdout)
84
+
85
+
86
+ def omp_groups_from_json(data: object) -> list[ModelGroup]:
87
+ """Parse ``omp models --json`` ({"models":[{provider,selector,name,contextWindow,...}]})."""
88
+ recs = data.get("models") if isinstance(data, dict) else data
89
+ groups: dict[str, ModelGroup] = {}
90
+ for r in recs or []:
91
+ if not isinstance(r, dict):
92
+ continue
93
+ prov = r.get("provider") or "?"
94
+ sel = r.get("selector") or (f"{prov}/{r['id']}" if r.get("id") else None)
95
+ if not sel:
96
+ continue
97
+ name = r.get("name") or r.get("id") or sel
98
+ ctx = r.get("contextWindow")
99
+ ctx_s = f" · {ctx // 1000}K" if isinstance(ctx, int) and ctx >= 1000 else ""
100
+ groups.setdefault(prov, ModelGroup(name=prov)).choices.append(
101
+ ModelChoice(label=f"{name}{ctx_s} [{sel}]", ref=AgentRef(model=sel))
102
+ )
103
+ return [groups[k] for k in sorted(groups)]
104
+
105
+
106
+ def pi_groups_from_text(text: str) -> list[ModelGroup]:
107
+ """Best-effort parse of ``pi --list-models`` text output (one model id per line)."""
108
+ groups: dict[str, ModelGroup] = {}
109
+ for line in text.splitlines():
110
+ s = line.strip()
111
+ if not s or s.startswith(("#", "-", "Usage", "Available", "Models")):
112
+ continue
113
+ tok = s.split()[0]
114
+ if "/" not in tok and " " in s: # a header-ish line, skip
115
+ continue
116
+ prov = tok.split("/")[0] if "/" in tok else "pi"
117
+ groups.setdefault(prov, ModelGroup(name=prov)).choices.append(
118
+ ModelChoice(label=tok, ref=AgentRef(model=tok))
119
+ )
120
+ if not any(g.choices for g in groups.values()):
121
+ return [ModelGroup(name="pi", installed=False,
122
+ note="`pi --list-models` returned no parseable models; edit config manually")]
123
+ return [groups[k] for k in sorted(groups)]
124
+
125
+
126
+ def _cli_groups(config: Config, timeout: float) -> list[ModelGroup]:
127
+ statuses = asyncio.run(discover(config.agents, timeout=timeout))
128
+ return groups_from_statuses(statuses)
129
+
130
+
131
+ def groups_from_statuses(statuses: list[AgentStatus]) -> list[ModelGroup]:
132
+ """One group per cli agent; each model becomes a {agent, model} choice."""
133
+ groups: list[ModelGroup] = []
134
+ for st in sorted(statuses, key=lambda s: (not s.installed, s.name)):
135
+ g = ModelGroup(name=st.name, installed=st.installed, note=st.note)
136
+ for m in st.models:
137
+ g.choices.append(ModelChoice(label=m, ref=AgentRef(agent=st.name, model=m)))
138
+ groups.append(g)
139
+ return groups
140
+
141
+
142
+ # --------------------------------------------------------------------------- #
143
+ # Formatting helpers
144
+ # --------------------------------------------------------------------------- #
145
+ def fmt_ref(ref: AgentRef | None) -> str:
146
+ if ref is None:
147
+ return "(unset)"
148
+ if ref.agent and ref.model:
149
+ return f"{ref.agent}:{ref.model}"
150
+ return ref.model or ref.agent or "(unset)"
151
+
152
+
153
+ def _ref_yaml(ref: AgentRef | None) -> str:
154
+ if ref is None or (not ref.agent and not ref.model):
155
+ return "{ }"
156
+ if ref.agent and ref.model:
157
+ return f"{{ agent: {ref.agent}, model: {ref.model} }}"
158
+ if ref.model:
159
+ return f"{{ model: {ref.model} }}"
160
+ return f"{{ agent: {ref.agent} }}"
161
+
162
+
163
+ def current_refs(config: Config) -> dict[str, AgentRef]:
164
+ m = config.models
165
+ return {"planner": m.planner, "main": m.main, "phase": m.phase, "task": m.task}
166
+
167
+
168
+ # --------------------------------------------------------------------------- #
169
+ # Interactive picker (injectable I/O for tests)
170
+ # --------------------------------------------------------------------------- #
171
+ _MODEL_PAGE = 25 # long provider lists prompt for a filter above this many
172
+
173
+
174
+ def pick_models(
175
+ groups: list[ModelGroup],
176
+ current: dict[str, AgentRef],
177
+ *,
178
+ roles: list[str] = ROLES,
179
+ input_fn: Callable[[str], str] | None = None,
180
+ out: Callable[[str], None] = print,
181
+ ) -> dict[str, AgentRef]:
182
+ """Two-step provider->model prompt per role. Returns only the roles the user
183
+ changed (empty dict if nothing changed / aborted)."""
184
+ if input_fn is None: # resolved at call time so a patched builtins.input is honored
185
+ input_fn = input
186
+ usable = [g for g in groups if g.installed and g.choices]
187
+ if not usable:
188
+ out("no connected models found (check the tool is installed and authenticated).")
189
+ for g in groups:
190
+ if g.note:
191
+ out(f" - {g.name}: {g.note}")
192
+ return {}
193
+
194
+ picks: dict[str, AgentRef] = {}
195
+ out("select a model per role — Enter keeps the current value, 'q' stops.\n")
196
+ try:
197
+ for role in roles:
198
+ out(f"── {role} (current: {fmt_ref(current.get(role))})")
199
+ g = _choose_group(usable, input_fn, out)
200
+ if g is _QUIT:
201
+ break
202
+ if g is None:
203
+ out(" kept.\n")
204
+ continue
205
+ ref = _choose_model(g, input_fn, out)
206
+ if ref is _QUIT:
207
+ break
208
+ if ref is None:
209
+ out(" kept.\n")
210
+ continue
211
+ picks[role] = ref
212
+ out(f" → {fmt_ref(ref)}\n")
213
+ except (EOFError, KeyboardInterrupt):
214
+ out("\naborted.")
215
+ return {}
216
+ return picks
217
+
218
+
219
+ _QUIT = object() # sentinel: user asked to stop
220
+
221
+
222
+ def _choose_group(groups: list[ModelGroup], input_fn, out):
223
+ for i, g in enumerate(groups, 1):
224
+ out(f" {i:>2}. {g.name} ({len(g.choices)})")
225
+ raw = input_fn(" provider # (Enter=keep, q=quit): ").strip().lower()
226
+ if raw in ("q", "quit"):
227
+ return _QUIT
228
+ if raw == "":
229
+ return None
230
+ if raw.isdigit() and 1 <= int(raw) <= len(groups):
231
+ return groups[int(raw) - 1]
232
+ out(" ? invalid choice; keeping current.")
233
+ return None
234
+
235
+
236
+ def _choose_model(group: ModelGroup, input_fn, out):
237
+ choices = group.choices
238
+ if len(choices) > _MODEL_PAGE:
239
+ flt = input_fn(f" {len(choices)} models in {group.name}; type a filter (Enter=show all): ").strip().lower()
240
+ if flt:
241
+ choices = [c for c in choices if flt in c.label.lower()] or group.choices
242
+ for i, c in enumerate(choices, 1):
243
+ out(f" {i:>2}. {c.label}")
244
+ raw = input_fn(" model # (Enter=keep, q=quit): ").strip().lower()
245
+ if raw in ("q", "quit"):
246
+ return _QUIT
247
+ if raw == "":
248
+ return None
249
+ if raw.isdigit() and 1 <= int(raw) <= len(choices):
250
+ return choices[int(raw) - 1].ref
251
+ out(" ? invalid choice; keeping current.")
252
+ return None
253
+
254
+
255
+ # --------------------------------------------------------------------------- #
256
+ # Config writing (surgical: replace only the models: block)
257
+ # --------------------------------------------------------------------------- #
258
+ def render_models_block(refs: dict[str, AgentRef], roles: list[str] = ROLES) -> str:
259
+ lines = ["models:"]
260
+ for r in roles:
261
+ lines.append(f" {(r + ':'):<9}{_ref_yaml(refs.get(r))}")
262
+ return "\n".join(lines)
263
+
264
+
265
+ def update_config_models(path: Path, refs: dict[str, AgentRef], roles: list[str] = ROLES) -> None:
266
+ """Replace the top-level ``models:`` block in the config, preserving the rest."""
267
+ text = path.read_text()
268
+ lines = text.splitlines()
269
+ start = next((i for i, l in enumerate(lines) if re.match(r"^models:\s*(#.*)?$", l)), None)
270
+ block = render_models_block(refs, roles).splitlines()
271
+ if start is None:
272
+ new = lines + ([""] if lines and lines[-1].strip() else []) + block
273
+ else:
274
+ end = start + 1
275
+ # Consume the block's role lines, but stop at an indented comment line so
276
+ # trailing comments inside the block are preserved (blank/dedented lines
277
+ # already end the block).
278
+ while end < len(lines) and lines[end].startswith((" ", "\t")):
279
+ if lines[end].lstrip().startswith("#"):
280
+ break
281
+ end += 1
282
+ new = lines[:start] + block + lines[end:]
283
+ path.write_text("\n".join(new) + ("\n" if text.endswith("\n") else ""))
284
+
285
+
286
+ # --------------------------------------------------------------------------- #
287
+ # Optional verification: ping each picked model once (no tools) to catch 401/500
288
+ # --------------------------------------------------------------------------- #
289
+ def verify_refs(config: Config, refs: dict[str, AgentRef], *, out: Callable[[str], None] = print) -> bool:
290
+ """Send a tiny no-tools prompt to each ref so runtime errors surface now, not
291
+ mid-run. Returns True if all succeeded."""
292
+ from .llm import CLIProvider, PiProvider
293
+
294
+ ok_all = True
295
+ out("verifying selections (one tiny call each) …")
296
+ for role, ref in refs.items():
297
+ try:
298
+ if config.provider == "pi":
299
+ prov = PiProvider(config.pi, timeout=60, max_retries=0)
300
+ asyncio.run(prov.complete(model=ref.model, system="", user="Reply with: ok", role="planner"))
301
+ else:
302
+ prov = CLIProvider(config.agents, timeout=60, max_retries=0)
303
+ asyncio.run(prov.complete(agent=ref.agent or None, model=ref.model,
304
+ system="", user="Reply with: ok", role="planner"))
305
+ out(f" ✓ {role} {fmt_ref(ref)}")
306
+ except Exception as e: # noqa: BLE001 - report any backend failure
307
+ ok_all = False
308
+ out(f" ✗ {role} {fmt_ref(ref)} — {str(e).splitlines()[0][:100]}")
309
+ return ok_all