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/__init__.py +10 -0
- roundtable/agents.py +218 -0
- roundtable/cli.py +722 -0
- roundtable/config.py +265 -0
- roundtable/dashboard.py +533 -0
- roundtable/discovery.py +71 -0
- roundtable/engine.py +714 -0
- roundtable/errors.py +20 -0
- roundtable/insights.py +210 -0
- roundtable/llm.py +1048 -0
- roundtable/mcp.py +248 -0
- roundtable/modelpick.py +309 -0
- roundtable/models.py +202 -0
- roundtable/prompts.py +205 -0
- roundtable/runctl.py +212 -0
- roundtable/scan.py +187 -0
- roundtable/store.py +339 -0
- roundtable_cli-0.4.0.dist-info/METADATA +570 -0
- roundtable_cli-0.4.0.dist-info/RECORD +22 -0
- roundtable_cli-0.4.0.dist-info/WHEEL +4 -0
- roundtable_cli-0.4.0.dist-info/entry_points.txt +4 -0
- roundtable_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
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()
|
roundtable/modelpick.py
ADDED
|
@@ -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
|