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/llm.py
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
"""LLM transport layer.
|
|
2
|
+
|
|
3
|
+
A small ``LLMProvider`` protocol decouples the orchestration engine from how
|
|
4
|
+
calls are made. Four backends ship:
|
|
5
|
+
|
|
6
|
+
* ``PiProvider`` — drive the ``pi`` coding agent for every role (recommended).
|
|
7
|
+
pi handles LLM connectivity/auth; task agents run with pi's file tools,
|
|
8
|
+
orchestrator roles run ``pi --no-tools``. Reports exact token usage and cost.
|
|
9
|
+
* ``CLIProvider`` — reach other LLMs through their terminal CLIs (claude, codex,
|
|
10
|
+
gemini, aider, llm, ollama, ...). Each "model" names a configured agent
|
|
11
|
+
command; the command runs in the project directory so the agent acts on real
|
|
12
|
+
files. This is the primary backend.
|
|
13
|
+
* ``LiteLLMProvider`` — direct API calls via litellm, so any provider/model
|
|
14
|
+
string works (``openai/...``, ``anthropic/...``, ``ollama/...``, ...).
|
|
15
|
+
* ``ScriptedProvider`` — a deterministic, offline backend that produces
|
|
16
|
+
structured, inspectable output for every role. Used for the offline demo
|
|
17
|
+
(``provider: scripted``) and for tests; the orchestration engine itself runs
|
|
18
|
+
for real against it.
|
|
19
|
+
|
|
20
|
+
``role`` and ``meta`` are optional call metadata: real backends use them for
|
|
21
|
+
logging, while the scripted backend uses ``role`` to branch.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
|
|
34
|
+
|
|
35
|
+
from .config import AgentSpec, PiOptions
|
|
36
|
+
from .errors import RoundtableError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class LLMProvider(Protocol):
|
|
41
|
+
async def complete(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
model: str,
|
|
45
|
+
system: str,
|
|
46
|
+
user: str,
|
|
47
|
+
agent: str | None = None,
|
|
48
|
+
json_mode: bool = False,
|
|
49
|
+
temperature: float = 0.2,
|
|
50
|
+
role: str | None = None,
|
|
51
|
+
meta: dict[str, Any] | None = None,
|
|
52
|
+
on_output: Callable[[str], None] | None = None,
|
|
53
|
+
) -> str: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class RunStats:
|
|
58
|
+
"""Accumulated usage statistics for a provider across an entire run.
|
|
59
|
+
|
|
60
|
+
``estimated`` is True when the token counts are approximations rather than
|
|
61
|
+
exact figures reported by the provider — the case for the CLI backend,
|
|
62
|
+
which only sees stdout and has no usage metadata to read.
|
|
63
|
+
"""
|
|
64
|
+
calls: int = 0
|
|
65
|
+
prompt_tokens: int = 0
|
|
66
|
+
completion_tokens: int = 0
|
|
67
|
+
total_tokens: int = 0
|
|
68
|
+
total_duration_s: float = 0.0
|
|
69
|
+
estimated: bool = False
|
|
70
|
+
cost_usd: float = 0.0 # real dollar cost when the backend reports it (pi); 0 otherwise
|
|
71
|
+
|
|
72
|
+
def snapshot(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"calls": self.calls,
|
|
75
|
+
"prompt_tokens": self.prompt_tokens,
|
|
76
|
+
"completion_tokens": self.completion_tokens,
|
|
77
|
+
"total_tokens": self.total_tokens,
|
|
78
|
+
"total_duration_s": round(self.total_duration_s, 2),
|
|
79
|
+
"estimated": self.estimated,
|
|
80
|
+
"cost_usd": round(self.cost_usd, 6),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def estimate_tokens(text: str) -> int:
|
|
85
|
+
"""Rough token count (~4 chars/token) for providers that report no usage.
|
|
86
|
+
|
|
87
|
+
Used by the CLI backend so a run surfaces an approximate token tally
|
|
88
|
+
instead of zeros. Flagged via ``RunStats.estimated`` so callers can label
|
|
89
|
+
it as an estimate rather than an exact count.
|
|
90
|
+
"""
|
|
91
|
+
if not text:
|
|
92
|
+
return 0
|
|
93
|
+
return max(1, len(text) // 4)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
# JSON extraction
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
def extract_json(text: str) -> dict[str, Any] | list:
|
|
100
|
+
"""Best-effort extraction of a JSON object or array from model output.
|
|
101
|
+
|
|
102
|
+
Handles raw JSON, ```json fenced blocks (including double-fenced), and
|
|
103
|
+
prose-wrapped objects/arrays by scanning for the first balanced ``{...}``
|
|
104
|
+
or ``[...]`` (string-aware).
|
|
105
|
+
"""
|
|
106
|
+
s = text.strip()
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(s)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# Strip code fences (handles double-fenced blocks too).
|
|
113
|
+
s = _strip_fences(s)
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(s)
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
# Find first balanced object or array.
|
|
120
|
+
obj_start = s.find("{")
|
|
121
|
+
arr_start = s.find("[")
|
|
122
|
+
|
|
123
|
+
# Pick whichever comes first; try both if present.
|
|
124
|
+
candidates: list[str | None] = []
|
|
125
|
+
if obj_start != -1 and (arr_start == -1 or obj_start <= arr_start):
|
|
126
|
+
candidates.append(_first_balanced(s, "{", "}"))
|
|
127
|
+
if arr_start != -1:
|
|
128
|
+
candidates.append(_first_balanced(s, "[", "]"))
|
|
129
|
+
elif arr_start != -1:
|
|
130
|
+
candidates.append(_first_balanced(s, "[", "]"))
|
|
131
|
+
if obj_start != -1:
|
|
132
|
+
candidates.append(_first_balanced(s, "{", "}"))
|
|
133
|
+
|
|
134
|
+
for c in candidates:
|
|
135
|
+
if c is not None:
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(c)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
raise ValueError(f"no JSON object found in model output:\n{text[:500]}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _strip_fences(s: str) -> str:
|
|
145
|
+
"""Strip one or two layers of code fences from a string."""
|
|
146
|
+
for _ in range(2): # handle double-fenced blocks
|
|
147
|
+
stripped = s.strip()
|
|
148
|
+
if stripped.startswith("```"):
|
|
149
|
+
stripped = stripped.split("\n", 1)[1] if "\n" in stripped else stripped
|
|
150
|
+
if stripped.rstrip().endswith("```"):
|
|
151
|
+
stripped = stripped.rstrip()[:-3]
|
|
152
|
+
s = stripped.strip()
|
|
153
|
+
else:
|
|
154
|
+
break
|
|
155
|
+
return s
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _first_balanced(s: str, open_ch: str, close_ch: str) -> str | None:
|
|
159
|
+
"""Find the first balanced substring delimited by open_ch/close_ch.
|
|
160
|
+
|
|
161
|
+
String-aware: braces inside JSON strings are not counted.
|
|
162
|
+
"""
|
|
163
|
+
start = s.find(open_ch)
|
|
164
|
+
if start == -1:
|
|
165
|
+
return None
|
|
166
|
+
depth = 0
|
|
167
|
+
in_str = False
|
|
168
|
+
esc = False
|
|
169
|
+
for i in range(start, len(s)):
|
|
170
|
+
c = s[i]
|
|
171
|
+
if in_str:
|
|
172
|
+
if esc:
|
|
173
|
+
esc = False
|
|
174
|
+
elif c == "\\":
|
|
175
|
+
esc = True
|
|
176
|
+
elif c == '"':
|
|
177
|
+
in_str = False
|
|
178
|
+
continue
|
|
179
|
+
if c == '"':
|
|
180
|
+
in_str = True
|
|
181
|
+
elif c == open_ch:
|
|
182
|
+
depth += 1
|
|
183
|
+
elif c == close_ch:
|
|
184
|
+
depth -= 1
|
|
185
|
+
if depth == 0:
|
|
186
|
+
return s[start : i + 1]
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _retry(coro_factory: Callable[[], Awaitable[Any]], *, attempts: int) -> Any:
|
|
191
|
+
last: Exception | None = None
|
|
192
|
+
for n in range(attempts):
|
|
193
|
+
try:
|
|
194
|
+
return await coro_factory()
|
|
195
|
+
except Exception as e: # noqa: BLE001 - transport-agnostic retry
|
|
196
|
+
last = e
|
|
197
|
+
if n < attempts - 1:
|
|
198
|
+
await asyncio.sleep(min(2.0 * (n + 1), 8.0))
|
|
199
|
+
assert last is not None
|
|
200
|
+
raise last
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
|
|
204
|
+
"""Terminate a subprocess, escalating to kill if it does not exit promptly."""
|
|
205
|
+
if proc.returncode is not None:
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
proc.terminate()
|
|
209
|
+
except ProcessLookupError:
|
|
210
|
+
return
|
|
211
|
+
try:
|
|
212
|
+
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
213
|
+
except asyncio.TimeoutError:
|
|
214
|
+
proc.kill()
|
|
215
|
+
await proc.wait()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _cancel_reader(task: "asyncio.Task[Any]") -> None:
|
|
219
|
+
"""Cancel a background stream-reader task and wait for it to settle, so it is
|
|
220
|
+
not left orphaned (which can log 'Task was destroyed but it is pending')."""
|
|
221
|
+
if task.done():
|
|
222
|
+
return
|
|
223
|
+
task.cancel()
|
|
224
|
+
try:
|
|
225
|
+
await task
|
|
226
|
+
except (asyncio.CancelledError, Exception):
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------- #
|
|
231
|
+
# Real backend
|
|
232
|
+
# --------------------------------------------------------------------------- #
|
|
233
|
+
class LiteLLMProvider:
|
|
234
|
+
"""Real LLM calls through litellm (any provider/model string)."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, max_retries: int = 2):
|
|
237
|
+
self.max_retries = max_retries
|
|
238
|
+
self.stats = RunStats()
|
|
239
|
+
|
|
240
|
+
async def complete(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
model: str,
|
|
244
|
+
system: str,
|
|
245
|
+
user: str,
|
|
246
|
+
agent: str | None = None,
|
|
247
|
+
json_mode: bool = False,
|
|
248
|
+
temperature: float = 0.2,
|
|
249
|
+
role: str | None = None,
|
|
250
|
+
meta: dict[str, Any] | None = None,
|
|
251
|
+
on_output: Callable[[str], None] | None = None, # accepted for interface parity; unused
|
|
252
|
+
) -> str:
|
|
253
|
+
try:
|
|
254
|
+
import litellm # lazy: only the litellm backend needs it
|
|
255
|
+
except ModuleNotFoundError as e:
|
|
256
|
+
raise RoundtableError(
|
|
257
|
+
"provider 'litellm' needs the litellm package: pip install 'roundtable-cli[litellm]' "
|
|
258
|
+
"(the default 'cli' provider needs no extra deps)"
|
|
259
|
+
) from e
|
|
260
|
+
|
|
261
|
+
# litellm has no separate command; the model string is the litellm model.
|
|
262
|
+
# Fall back to `agent` so a bare `agent`-only ref still works as the model.
|
|
263
|
+
litellm_model = model or (agent or "")
|
|
264
|
+
messages = [
|
|
265
|
+
{"role": "system", "content": system},
|
|
266
|
+
{"role": "user", "content": user},
|
|
267
|
+
]
|
|
268
|
+
kwargs: dict[str, Any] = {"model": litellm_model, "messages": messages, "temperature": temperature}
|
|
269
|
+
if json_mode:
|
|
270
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
271
|
+
|
|
272
|
+
t0 = time.monotonic()
|
|
273
|
+
|
|
274
|
+
async def call_with_json() -> str:
|
|
275
|
+
resp = await litellm.acompletion(**kwargs)
|
|
276
|
+
self._record_usage(resp)
|
|
277
|
+
return resp.choices[0].message.content or ""
|
|
278
|
+
|
|
279
|
+
async def call_plain() -> str:
|
|
280
|
+
plain = {k: v for k, v in kwargs.items() if k != "response_format"}
|
|
281
|
+
resp = await litellm.acompletion(**plain)
|
|
282
|
+
self._record_usage(resp)
|
|
283
|
+
return resp.choices[0].message.content or ""
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
result = await _retry(call_with_json, attempts=self.max_retries + 1)
|
|
287
|
+
except Exception:
|
|
288
|
+
if not json_mode:
|
|
289
|
+
raise
|
|
290
|
+
# Some providers reject response_format; fall back to plain prompting.
|
|
291
|
+
result = await _retry(call_plain, attempts=self.max_retries + 1)
|
|
292
|
+
|
|
293
|
+
self.stats.total_duration_s += time.monotonic() - t0
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
def _record_usage(self, resp: Any) -> None:
|
|
297
|
+
"""Accumulate token counts from a litellm response."""
|
|
298
|
+
self.stats.calls += 1
|
|
299
|
+
usage = getattr(resp, "usage", None)
|
|
300
|
+
if usage:
|
|
301
|
+
self.stats.prompt_tokens += getattr(usage, "prompt_tokens", 0) or 0
|
|
302
|
+
self.stats.completion_tokens += getattr(usage, "completion_tokens", 0) or 0
|
|
303
|
+
self.stats.total_tokens += getattr(usage, "total_tokens", 0) or 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --------------------------------------------------------------------------- #
|
|
307
|
+
# Terminal / CLI backend (the main mode): reach other LLMs via their CLIs
|
|
308
|
+
# --------------------------------------------------------------------------- #
|
|
309
|
+
class CLIProvider:
|
|
310
|
+
"""Run other LLMs through their terminal CLIs.
|
|
311
|
+
|
|
312
|
+
Each ``model`` names an entry in ``agents``; its ``command`` (an argv list)
|
|
313
|
+
is executed in ``cwd`` (the target project), with ``{prompt}``/``{system}``
|
|
314
|
+
substituted or the prompt piped on stdin. stdout is the response.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def __init__(
|
|
318
|
+
self,
|
|
319
|
+
agents: dict[str, AgentSpec],
|
|
320
|
+
*,
|
|
321
|
+
cwd: str | Path | None = None,
|
|
322
|
+
timeout: int = 900,
|
|
323
|
+
max_retries: int = 1,
|
|
324
|
+
):
|
|
325
|
+
self.agents = agents
|
|
326
|
+
self.cwd = str(cwd) if cwd else None
|
|
327
|
+
self.timeout = timeout
|
|
328
|
+
self.max_retries = max_retries
|
|
329
|
+
self.stats = RunStats()
|
|
330
|
+
|
|
331
|
+
async def complete(
|
|
332
|
+
self,
|
|
333
|
+
*,
|
|
334
|
+
model: str,
|
|
335
|
+
system: str,
|
|
336
|
+
user: str,
|
|
337
|
+
agent: str | None = None,
|
|
338
|
+
json_mode: bool = False,
|
|
339
|
+
temperature: float = 0.2,
|
|
340
|
+
role: str | None = None,
|
|
341
|
+
meta: dict[str, Any] | None = None,
|
|
342
|
+
on_output: Callable[[str], None] | None = None,
|
|
343
|
+
) -> str:
|
|
344
|
+
# When `agent` is given, it names the command and `model` is the {model}
|
|
345
|
+
# token. With only `model` (back-compat), `model` itself names the agent.
|
|
346
|
+
if agent:
|
|
347
|
+
agent_key, model_token = agent, model
|
|
348
|
+
else:
|
|
349
|
+
agent_key, model_token = model, ""
|
|
350
|
+
|
|
351
|
+
spec = self.agents.get(agent_key)
|
|
352
|
+
if spec is None:
|
|
353
|
+
raise RoundtableError(
|
|
354
|
+
f"agent {agent_key!r} is not defined under `agents:` in roundtable.config.yaml; "
|
|
355
|
+
f"available: {', '.join(sorted(self.agents)) or '(none)'}"
|
|
356
|
+
)
|
|
357
|
+
if json_mode:
|
|
358
|
+
user = user + "\n\nRespond with ONLY the JSON object, no prose or code fences."
|
|
359
|
+
|
|
360
|
+
argv, stdin_data = _render_command(spec, system, user, model_token)
|
|
361
|
+
exe = shutil.which(argv[0])
|
|
362
|
+
if exe is None:
|
|
363
|
+
raise RoundtableError(
|
|
364
|
+
f"agent {agent_key!r}: command {argv[0]!r} not found on PATH "
|
|
365
|
+
f"(install it or fix `agents.{agent_key}.command`)"
|
|
366
|
+
)
|
|
367
|
+
argv[0] = exe
|
|
368
|
+
use_pty = spec.pty
|
|
369
|
+
t0 = time.monotonic()
|
|
370
|
+
result = await _retry(
|
|
371
|
+
lambda: self._run(argv, stdin_data, agent_key, use_pty, on_output),
|
|
372
|
+
attempts=self.max_retries + 1,
|
|
373
|
+
)
|
|
374
|
+
self.stats.calls += 1
|
|
375
|
+
self.stats.total_duration_s += time.monotonic() - t0
|
|
376
|
+
# A CLI returns only stdout — no usage metadata — so approximate tokens
|
|
377
|
+
# from text length and mark the run's stats as estimated.
|
|
378
|
+
prompt_est = estimate_tokens(system) + estimate_tokens(user)
|
|
379
|
+
completion_est = estimate_tokens(result)
|
|
380
|
+
self.stats.prompt_tokens += prompt_est
|
|
381
|
+
self.stats.completion_tokens += completion_est
|
|
382
|
+
self.stats.total_tokens += prompt_est + completion_est
|
|
383
|
+
self.stats.estimated = True
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
async def _run(
|
|
387
|
+
self, argv: list[str], stdin_data: str | None, model: str,
|
|
388
|
+
use_pty: bool = False, on_output: Callable[[str], None] | None = None,
|
|
389
|
+
) -> str:
|
|
390
|
+
if use_pty:
|
|
391
|
+
return await self._run_pty(argv, stdin_data, model, on_output)
|
|
392
|
+
proc = await asyncio.create_subprocess_exec(
|
|
393
|
+
*argv,
|
|
394
|
+
cwd=self.cwd,
|
|
395
|
+
stdin=asyncio.subprocess.PIPE if stdin_data is not None else None,
|
|
396
|
+
stdout=asyncio.subprocess.PIPE,
|
|
397
|
+
stderr=asyncio.subprocess.PIPE,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if on_output and proc.stdout:
|
|
401
|
+
# Stream stdout line-by-line while also collecting the full output.
|
|
402
|
+
buf: list[str] = []
|
|
403
|
+
batch: list[str] = []
|
|
404
|
+
batch_count = 0
|
|
405
|
+
last_flush = time.monotonic()
|
|
406
|
+
|
|
407
|
+
async def drain_stderr() -> bytes:
|
|
408
|
+
assert proc.stderr is not None
|
|
409
|
+
return await proc.stderr.read()
|
|
410
|
+
|
|
411
|
+
stderr_task = asyncio.create_task(drain_stderr())
|
|
412
|
+
|
|
413
|
+
# Feed stdin if needed.
|
|
414
|
+
if stdin_data is not None and proc.stdin is not None:
|
|
415
|
+
proc.stdin.write(stdin_data.encode())
|
|
416
|
+
await proc.stdin.drain()
|
|
417
|
+
proc.stdin.close()
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
async with asyncio.timeout(self.timeout):
|
|
421
|
+
async for raw_line in proc.stdout:
|
|
422
|
+
line = raw_line.decode("utf-8", "replace")
|
|
423
|
+
buf.append(line)
|
|
424
|
+
batch.append(line)
|
|
425
|
+
batch_count += 1
|
|
426
|
+
now = time.monotonic()
|
|
427
|
+
if batch_count >= 10 or (now - last_flush) >= 5.0:
|
|
428
|
+
on_output("".join(batch))
|
|
429
|
+
batch.clear()
|
|
430
|
+
batch_count = 0
|
|
431
|
+
last_flush = now
|
|
432
|
+
# Flush remaining lines.
|
|
433
|
+
if batch:
|
|
434
|
+
on_output("".join(batch))
|
|
435
|
+
await proc.wait()
|
|
436
|
+
except TimeoutError:
|
|
437
|
+
await _terminate_process(proc)
|
|
438
|
+
await _cancel_reader(stderr_task)
|
|
439
|
+
raise TimeoutError(f"agent {model!r} timed out after {self.timeout}s")
|
|
440
|
+
except asyncio.CancelledError:
|
|
441
|
+
await _terminate_process(proc)
|
|
442
|
+
await _cancel_reader(stderr_task)
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
err = await stderr_task
|
|
446
|
+
out_text = "".join(buf)
|
|
447
|
+
if proc.returncode != 0:
|
|
448
|
+
err_text = err.decode("utf-8", "replace").strip()
|
|
449
|
+
detail = (err_text or out_text.strip() or "(no output on stdout/stderr)")[-800:]
|
|
450
|
+
raise RuntimeError(f"agent {model!r} exited {proc.returncode}: {detail}")
|
|
451
|
+
return out_text
|
|
452
|
+
|
|
453
|
+
# Non-streaming path (original behavior).
|
|
454
|
+
try:
|
|
455
|
+
out, err = await asyncio.wait_for(
|
|
456
|
+
proc.communicate(stdin_data.encode() if stdin_data is not None else None),
|
|
457
|
+
timeout=self.timeout,
|
|
458
|
+
)
|
|
459
|
+
except asyncio.TimeoutError:
|
|
460
|
+
await _terminate_process(proc)
|
|
461
|
+
raise TimeoutError(f"agent {model!r} timed out after {self.timeout}s")
|
|
462
|
+
except asyncio.CancelledError:
|
|
463
|
+
await _terminate_process(proc)
|
|
464
|
+
raise
|
|
465
|
+
if proc.returncode != 0:
|
|
466
|
+
# Many CLIs print the actual error to stdout, not stderr (e.g. claude's
|
|
467
|
+
# "model ... may not exist"), so surface whichever stream has content.
|
|
468
|
+
err_text = err.decode("utf-8", "replace").strip()
|
|
469
|
+
out_text = out.decode("utf-8", "replace").strip()
|
|
470
|
+
detail = (err_text or out_text or "(no output on stdout/stderr)")[-800:]
|
|
471
|
+
raise RuntimeError(f"agent {model!r} exited {proc.returncode}: {detail}")
|
|
472
|
+
return out.decode("utf-8", "replace")
|
|
473
|
+
|
|
474
|
+
async def _run_pty(
|
|
475
|
+
self, argv: list[str], stdin_data: str | None, model: str,
|
|
476
|
+
on_output: Callable[[str], None] | None = None,
|
|
477
|
+
) -> str:
|
|
478
|
+
"""Run argv in a pseudo-terminal so the subprocess sees a real TTY."""
|
|
479
|
+
try:
|
|
480
|
+
import pty as _pty
|
|
481
|
+
import os as _os
|
|
482
|
+
except ImportError:
|
|
483
|
+
raise RoundtableError("pty mode requires the 'pty' stdlib module (Unix/macOS only)")
|
|
484
|
+
|
|
485
|
+
master_fd, slave_fd = _pty.openpty()
|
|
486
|
+
proc = await asyncio.create_subprocess_exec(
|
|
487
|
+
*argv,
|
|
488
|
+
cwd=self.cwd,
|
|
489
|
+
stdin=slave_fd,
|
|
490
|
+
stdout=slave_fd,
|
|
491
|
+
stderr=slave_fd,
|
|
492
|
+
)
|
|
493
|
+
_os.close(slave_fd) # parent does not need the slave end
|
|
494
|
+
|
|
495
|
+
if stdin_data is not None:
|
|
496
|
+
try:
|
|
497
|
+
_os.write(master_fd, stdin_data.encode())
|
|
498
|
+
except OSError:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
loop = asyncio.get_running_loop()
|
|
502
|
+
chunks: list[bytes] = []
|
|
503
|
+
|
|
504
|
+
def _drain() -> None:
|
|
505
|
+
batch_bytes: list[bytes] = []
|
|
506
|
+
batch_size = 0
|
|
507
|
+
last_flush = time.monotonic()
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
data = _os.read(master_fd, 4096)
|
|
511
|
+
if not data:
|
|
512
|
+
break
|
|
513
|
+
chunks.append(data)
|
|
514
|
+
if on_output:
|
|
515
|
+
batch_bytes.append(data)
|
|
516
|
+
batch_size += len(data)
|
|
517
|
+
now = time.monotonic()
|
|
518
|
+
if batch_size >= 4096 or (now - last_flush) >= 5.0:
|
|
519
|
+
text = b"".join(batch_bytes).decode("utf-8", "replace")
|
|
520
|
+
on_output(text.replace("\r\n", "\n").replace("\r", ""))
|
|
521
|
+
batch_bytes.clear()
|
|
522
|
+
batch_size = 0
|
|
523
|
+
last_flush = now
|
|
524
|
+
except OSError:
|
|
525
|
+
break # EIO/ENXIO when slave closes — normal PTY EOF
|
|
526
|
+
# Flush remaining.
|
|
527
|
+
if on_output and batch_bytes:
|
|
528
|
+
text = b"".join(batch_bytes).decode("utf-8", "replace")
|
|
529
|
+
on_output(text.replace("\r\n", "\n").replace("\r", ""))
|
|
530
|
+
try:
|
|
531
|
+
_os.close(master_fd)
|
|
532
|
+
except OSError:
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
drain_fut = loop.run_in_executor(None, _drain)
|
|
536
|
+
try:
|
|
537
|
+
await asyncio.wait_for(proc.wait(), timeout=self.timeout)
|
|
538
|
+
except asyncio.TimeoutError:
|
|
539
|
+
await _terminate_process(proc)
|
|
540
|
+
await drain_fut
|
|
541
|
+
raise TimeoutError(f"agent {model!r} timed out after {self.timeout}s")
|
|
542
|
+
except asyncio.CancelledError:
|
|
543
|
+
await _terminate_process(proc)
|
|
544
|
+
await drain_fut
|
|
545
|
+
raise
|
|
546
|
+
|
|
547
|
+
await drain_fut
|
|
548
|
+
|
|
549
|
+
# PTY driver does CR+LF translation; normalise to LF only.
|
|
550
|
+
output = b"".join(chunks).decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")
|
|
551
|
+
if proc.returncode != 0:
|
|
552
|
+
raise RuntimeError(f"agent {model!r} exited {proc.returncode}: {output.strip()[-800:]}")
|
|
553
|
+
return output
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _render_command(
|
|
557
|
+
spec: AgentSpec, system: str, user: str, model: str = ""
|
|
558
|
+
) -> tuple[list[str], str | None]:
|
|
559
|
+
"""Build argv + optional stdin payload from a command template.
|
|
560
|
+
|
|
561
|
+
``{model}`` is replaced with the chosen model token (empty string if none).
|
|
562
|
+
"""
|
|
563
|
+
has_system = any("{system}" in tok for tok in spec.command)
|
|
564
|
+
payload = user if has_system else (f"{system}\n\n{user}" if system else user)
|
|
565
|
+
argv: list[str] = []
|
|
566
|
+
for tok in spec.command:
|
|
567
|
+
t = tok.replace("{system}", system or "").replace("{model}", model or "")
|
|
568
|
+
if "{prompt}" in t:
|
|
569
|
+
t = t.replace("{prompt}", "" if spec.stdin else payload)
|
|
570
|
+
argv.append(t)
|
|
571
|
+
return argv, (payload if spec.stdin else None)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# --------------------------------------------------------------------------- #
|
|
575
|
+
# Pi backend (recommended): drive the `pi` coding agent for every role
|
|
576
|
+
# --------------------------------------------------------------------------- #
|
|
577
|
+
# Only this role gets pi's file tools (it edits the repo). Every other role runs
|
|
578
|
+
# `pi --no-tools` as a pure completion.
|
|
579
|
+
PI_WORKER_ROLE = "task_exec"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _pi_flavor(options: PiOptions) -> tuple[list[str], list[str], list[str]]:
|
|
583
|
+
"""Resolve (command, worker_flags, orchestrator_flags) for a pi flavor.
|
|
584
|
+
|
|
585
|
+
Both flavors share the core contract; only two things differ:
|
|
586
|
+
* ``pi`` — orchestrator roles skip the repo's context files (``--no-context-files``)
|
|
587
|
+
unless ``orchestrator_context_files`` is set.
|
|
588
|
+
* ``omp`` — has no ``--no-context-files``, and gates edits behind approval, so
|
|
589
|
+
task (worker) agents get ``--auto-approve`` to run autonomously.
|
|
590
|
+
User ``worker_extra_args`` / ``orchestrator_extra_args`` are appended on top.
|
|
591
|
+
"""
|
|
592
|
+
flavor = (options.flavor or "pi").lower()
|
|
593
|
+
if flavor == "omp":
|
|
594
|
+
command = list(options.command) or ["omp"]
|
|
595
|
+
worker = ["--auto-approve"]
|
|
596
|
+
orch: list[str] = []
|
|
597
|
+
else: # "pi"
|
|
598
|
+
command = list(options.command) or ["pi"]
|
|
599
|
+
worker = []
|
|
600
|
+
orch = [] if options.orchestrator_context_files else ["--no-context-files"]
|
|
601
|
+
worker += list(options.worker_extra_args)
|
|
602
|
+
orch += list(options.orchestrator_extra_args)
|
|
603
|
+
return command, worker, orch
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_pi_argv(
|
|
607
|
+
options: PiOptions, *, model: str, system: str, is_worker: bool
|
|
608
|
+
) -> list[str]:
|
|
609
|
+
"""Build the pi/omp flag argv for a role (without the user prompt — that is
|
|
610
|
+
delivered per flavor by :func:`_pi_prompt_delivery`). The system prompt goes
|
|
611
|
+
via a flag.
|
|
612
|
+
|
|
613
|
+
Worker (``task_exec``) keeps file tools and *appends* roundtable's task
|
|
614
|
+
instructions to the tool's coding system prompt. Orchestrator roles disable
|
|
615
|
+
tools and *replace* the system prompt (they only reason/write).
|
|
616
|
+
"""
|
|
617
|
+
command, worker_flags, orch_flags = _pi_flavor(options)
|
|
618
|
+
argv = list(command) + ["--mode", "json"]
|
|
619
|
+
if not is_worker:
|
|
620
|
+
argv += ["--no-tools"]
|
|
621
|
+
if model:
|
|
622
|
+
argv += ["--model", model]
|
|
623
|
+
if system:
|
|
624
|
+
argv += (["--append-system-prompt", system] if is_worker else ["--system-prompt", system])
|
|
625
|
+
argv += (worker_flags if is_worker else orch_flags)
|
|
626
|
+
argv += list(options.extra_args)
|
|
627
|
+
return argv
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _pi_prompt_delivery(flavor: str, argv: list[str], payload: str) -> tuple[list[str], str | None]:
|
|
631
|
+
"""Attach the user prompt to the invocation the way the flavor expects.
|
|
632
|
+
|
|
633
|
+
Upstream ``pi`` reads a piped prompt from stdin; ``omp`` ignores stdin and
|
|
634
|
+
takes the prompt as a positional argument (``--`` guards prompts that start
|
|
635
|
+
with ``-``/``@``). Returns ``(argv, stdin_data)`` where ``stdin_data`` is None
|
|
636
|
+
when the prompt is passed as an argument.
|
|
637
|
+
"""
|
|
638
|
+
if (flavor or "pi").lower() == "omp":
|
|
639
|
+
return argv + ["-p", "--", payload], None
|
|
640
|
+
return argv, payload
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _assistant_text_usage(msg: Any) -> tuple[str, dict[str, float], str | None] | None:
|
|
644
|
+
"""Extract (text, usage, error) from a pi assistant message, or None if the
|
|
645
|
+
message is not an assistant turn. ``usage`` keys: input/output/total/cost."""
|
|
646
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
647
|
+
return None
|
|
648
|
+
text = "".join(
|
|
649
|
+
c.get("text", "")
|
|
650
|
+
for c in msg.get("content", [])
|
|
651
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
652
|
+
)
|
|
653
|
+
usage = msg.get("usage") or {}
|
|
654
|
+
cost = usage.get("cost") or {}
|
|
655
|
+
u = {
|
|
656
|
+
"input": float(usage.get("input", 0) or 0),
|
|
657
|
+
"output": float(usage.get("output", 0) or 0),
|
|
658
|
+
"total": float(usage.get("totalTokens", 0) or 0),
|
|
659
|
+
"cost": float(cost.get("total", 0.0) or 0.0),
|
|
660
|
+
}
|
|
661
|
+
if not u["total"]:
|
|
662
|
+
u["total"] = u["input"] + u["output"]
|
|
663
|
+
stop = msg.get("stopReason")
|
|
664
|
+
err = msg.get("errorMessage") or f"pi stopped: {stop}" if stop in ("error", "aborted") else None
|
|
665
|
+
return text, u, err
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def parse_pi_events(stdout: str) -> tuple[str, dict[str, float]]:
|
|
669
|
+
"""Parse a ``pi --mode json`` stdout stream into (final_text, usage).
|
|
670
|
+
|
|
671
|
+
Usage is summed across assistant ``message_end`` events (each message counted
|
|
672
|
+
once); the final text is the last assistant message that carried text. Raises
|
|
673
|
+
:class:`RoundtableError` if any assistant turn reported an error/abort. Falls
|
|
674
|
+
back to the terminal ``agent_end`` message list if no ``message_end`` events
|
|
675
|
+
were seen (older/edge pi builds).
|
|
676
|
+
"""
|
|
677
|
+
totals = {"input": 0.0, "output": 0.0, "total": 0.0, "cost": 0.0}
|
|
678
|
+
final_text = ""
|
|
679
|
+
saw_message_end = False
|
|
680
|
+
error: str | None = None
|
|
681
|
+
agent_end_msgs: list[Any] | None = None
|
|
682
|
+
|
|
683
|
+
for line in stdout.splitlines():
|
|
684
|
+
line = line.strip()
|
|
685
|
+
if not line:
|
|
686
|
+
continue
|
|
687
|
+
try:
|
|
688
|
+
ev = json.loads(line)
|
|
689
|
+
except json.JSONDecodeError:
|
|
690
|
+
continue
|
|
691
|
+
if not isinstance(ev, dict):
|
|
692
|
+
continue
|
|
693
|
+
etype = ev.get("type")
|
|
694
|
+
if etype == "message_end":
|
|
695
|
+
parsed = _assistant_text_usage(ev.get("message"))
|
|
696
|
+
if parsed is None:
|
|
697
|
+
continue
|
|
698
|
+
saw_message_end = True
|
|
699
|
+
text, u, err = parsed
|
|
700
|
+
for k in totals:
|
|
701
|
+
totals[k] += u[k]
|
|
702
|
+
if text.strip():
|
|
703
|
+
final_text = text
|
|
704
|
+
if err and not error:
|
|
705
|
+
error = err
|
|
706
|
+
elif etype == "agent_end":
|
|
707
|
+
msgs = ev.get("messages")
|
|
708
|
+
if isinstance(msgs, list):
|
|
709
|
+
agent_end_msgs = msgs
|
|
710
|
+
|
|
711
|
+
if not saw_message_end and agent_end_msgs is not None:
|
|
712
|
+
for msg in agent_end_msgs:
|
|
713
|
+
parsed = _assistant_text_usage(msg)
|
|
714
|
+
if parsed is None:
|
|
715
|
+
continue
|
|
716
|
+
text, u, err = parsed
|
|
717
|
+
for k in totals:
|
|
718
|
+
totals[k] += u[k]
|
|
719
|
+
if text.strip():
|
|
720
|
+
final_text = text
|
|
721
|
+
if err and not error:
|
|
722
|
+
error = err
|
|
723
|
+
|
|
724
|
+
if error:
|
|
725
|
+
raise RoundtableError(f"pi agent error: {error}")
|
|
726
|
+
return final_text, totals
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class PiProvider:
|
|
730
|
+
"""Drive the ``pi`` coding agent for every role (recommended backend).
|
|
731
|
+
|
|
732
|
+
pi handles LLM connectivity, auth and model routing. Task agents run with pi's
|
|
733
|
+
file tools so they edit the real project; all other roles run ``pi --no-tools``
|
|
734
|
+
as pure completions. pi is invoked with ``--mode json`` and the event stream is
|
|
735
|
+
parsed for the final answer plus **exact** token usage and dollar cost.
|
|
736
|
+
"""
|
|
737
|
+
|
|
738
|
+
def __init__(
|
|
739
|
+
self,
|
|
740
|
+
options: PiOptions,
|
|
741
|
+
*,
|
|
742
|
+
cwd: str | Path | None = None,
|
|
743
|
+
timeout: int = 900,
|
|
744
|
+
max_retries: int = 1,
|
|
745
|
+
):
|
|
746
|
+
self.options = options
|
|
747
|
+
self.cwd = str(cwd) if cwd else None
|
|
748
|
+
self.timeout = timeout
|
|
749
|
+
self.max_retries = max_retries
|
|
750
|
+
self.stats = RunStats()
|
|
751
|
+
|
|
752
|
+
async def complete(
|
|
753
|
+
self,
|
|
754
|
+
*,
|
|
755
|
+
model: str,
|
|
756
|
+
system: str,
|
|
757
|
+
user: str,
|
|
758
|
+
agent: str | None = None, # ignored: pi is the agent
|
|
759
|
+
json_mode: bool = False,
|
|
760
|
+
temperature: float = 0.2, # accepted for interface parity; pi controls its own sampling
|
|
761
|
+
role: str | None = None,
|
|
762
|
+
meta: dict[str, Any] | None = None,
|
|
763
|
+
on_output: Callable[[str], None] | None = None,
|
|
764
|
+
) -> str:
|
|
765
|
+
is_worker = role == PI_WORKER_ROLE
|
|
766
|
+
argv = _build_pi_argv(self.options, model=model, system=system, is_worker=is_worker)
|
|
767
|
+
exe = shutil.which(argv[0])
|
|
768
|
+
if exe is None:
|
|
769
|
+
flavor = (self.options.flavor or "pi").lower()
|
|
770
|
+
install = (
|
|
771
|
+
"npm install -g @oh-my-pi/pi-coding-agent"
|
|
772
|
+
if flavor == "omp"
|
|
773
|
+
else "npm install -g @earendil-works/pi-coding-agent"
|
|
774
|
+
)
|
|
775
|
+
raise RoundtableError(
|
|
776
|
+
f"provider 'pi' (flavor {flavor!r}) needs the {argv[0]!r} CLI on PATH. "
|
|
777
|
+
f"Install it ({install}) and connect an LLM (set ANTHROPIC_API_KEY / "
|
|
778
|
+
"OPENAI_API_KEY / ... or use the tool's login), or switch `provider:` to "
|
|
779
|
+
"'cli'/'litellm' in roundtable.config.yaml."
|
|
780
|
+
)
|
|
781
|
+
argv[0] = exe
|
|
782
|
+
|
|
783
|
+
payload = user
|
|
784
|
+
if json_mode:
|
|
785
|
+
payload = user + "\n\nRespond with ONLY the JSON object, no prose or code fences."
|
|
786
|
+
run_argv, stdin_data = _pi_prompt_delivery(self.options.flavor, argv, payload)
|
|
787
|
+
|
|
788
|
+
t0 = time.monotonic()
|
|
789
|
+
raw = await _retry(
|
|
790
|
+
lambda: self._run(run_argv, stdin_data, role or "pi", on_output),
|
|
791
|
+
attempts=self.max_retries + 1,
|
|
792
|
+
)
|
|
793
|
+
text, usage = parse_pi_events(raw)
|
|
794
|
+
self.stats.calls += 1
|
|
795
|
+
self.stats.total_duration_s += time.monotonic() - t0
|
|
796
|
+
self.stats.prompt_tokens += int(usage["input"])
|
|
797
|
+
self.stats.completion_tokens += int(usage["output"])
|
|
798
|
+
self.stats.total_tokens += int(usage["total"])
|
|
799
|
+
self.stats.cost_usd += usage["cost"]
|
|
800
|
+
return text
|
|
801
|
+
|
|
802
|
+
async def _run(
|
|
803
|
+
self, argv: list[str], stdin_data: str | None, role: str,
|
|
804
|
+
on_output: Callable[[str], None] | None = None,
|
|
805
|
+
) -> str:
|
|
806
|
+
proc = await asyncio.create_subprocess_exec(
|
|
807
|
+
*argv,
|
|
808
|
+
cwd=self.cwd,
|
|
809
|
+
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
|
810
|
+
stdout=asyncio.subprocess.PIPE,
|
|
811
|
+
stderr=asyncio.subprocess.PIPE,
|
|
812
|
+
)
|
|
813
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
814
|
+
stderr_task = asyncio.create_task(proc.stderr.read())
|
|
815
|
+
|
|
816
|
+
if stdin_data is not None and proc.stdin is not None:
|
|
817
|
+
proc.stdin.write(stdin_data.encode())
|
|
818
|
+
await proc.stdin.drain()
|
|
819
|
+
proc.stdin.close()
|
|
820
|
+
|
|
821
|
+
# Read in chunks and split lines ourselves. pi/omp emit one JSON event per
|
|
822
|
+
# line, and a single event (an assistant message or tool result carrying a
|
|
823
|
+
# whole generated file) can be many MB — far past asyncio's default 64KB
|
|
824
|
+
# readline limit, which would raise "Separator is found, but chunk is longer
|
|
825
|
+
# than limit". Chunked reads have no such cap.
|
|
826
|
+
lines: list[str] = []
|
|
827
|
+
buf = b""
|
|
828
|
+
|
|
829
|
+
def _emit(line: str) -> None:
|
|
830
|
+
lines.append(line)
|
|
831
|
+
if on_output:
|
|
832
|
+
summary = _pi_event_summary(line)
|
|
833
|
+
if summary:
|
|
834
|
+
on_output(summary)
|
|
835
|
+
|
|
836
|
+
try:
|
|
837
|
+
async with asyncio.timeout(self.timeout):
|
|
838
|
+
while True:
|
|
839
|
+
chunk = await proc.stdout.read(65536)
|
|
840
|
+
if not chunk:
|
|
841
|
+
break
|
|
842
|
+
buf += chunk
|
|
843
|
+
nl = buf.rfind(b"\n")
|
|
844
|
+
if nl != -1:
|
|
845
|
+
complete, buf = buf[: nl + 1], buf[nl + 1 :]
|
|
846
|
+
for raw in complete.splitlines(keepends=True):
|
|
847
|
+
_emit(raw.decode("utf-8", "replace"))
|
|
848
|
+
if buf: # trailing line without a newline
|
|
849
|
+
_emit(buf.decode("utf-8", "replace"))
|
|
850
|
+
await proc.wait()
|
|
851
|
+
except TimeoutError:
|
|
852
|
+
await _terminate_process(proc)
|
|
853
|
+
await _cancel_reader(stderr_task)
|
|
854
|
+
raise TimeoutError(f"pi ({role}) timed out after {self.timeout}s")
|
|
855
|
+
except asyncio.CancelledError:
|
|
856
|
+
await _terminate_process(proc)
|
|
857
|
+
await _cancel_reader(stderr_task)
|
|
858
|
+
raise
|
|
859
|
+
|
|
860
|
+
err = await stderr_task
|
|
861
|
+
out = "".join(lines)
|
|
862
|
+
if proc.returncode != 0:
|
|
863
|
+
err_text = err.decode("utf-8", "replace").strip()
|
|
864
|
+
detail = (err_text or out.strip() or "(no output on stdout/stderr)")[-800:]
|
|
865
|
+
auth = _auth_error(detail)
|
|
866
|
+
if auth:
|
|
867
|
+
raise RoundtableError(
|
|
868
|
+
f"pi ({role}) could not authenticate provider {auth!r}; "
|
|
869
|
+
f"run the pi/omp login flow for {auth} or set its API key, "
|
|
870
|
+
"then retry."
|
|
871
|
+
)
|
|
872
|
+
raise RuntimeError(f"pi ({role}) exited {proc.returncode}: {detail}")
|
|
873
|
+
return out
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _auth_error(text: str) -> str | None:
|
|
877
|
+
m = re.search(r"No API key found for ([A-Za-z0-9_.-]+)", text)
|
|
878
|
+
return m.group(1) if m else None
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _pi_event_summary(line: str) -> str | None:
|
|
882
|
+
"""A short human line for the live dashboard from one pi json event (or None)."""
|
|
883
|
+
line = line.strip()
|
|
884
|
+
if not line:
|
|
885
|
+
return None
|
|
886
|
+
try:
|
|
887
|
+
ev = json.loads(line)
|
|
888
|
+
except json.JSONDecodeError:
|
|
889
|
+
return None
|
|
890
|
+
if not isinstance(ev, dict):
|
|
891
|
+
return None
|
|
892
|
+
etype = ev.get("type")
|
|
893
|
+
if etype == "message_end":
|
|
894
|
+
parsed = _assistant_text_usage(ev.get("message"))
|
|
895
|
+
if parsed and parsed[0].strip():
|
|
896
|
+
return parsed[0]
|
|
897
|
+
elif etype in ("tool_execution_start", "tool_call"):
|
|
898
|
+
name = ev.get("toolName") or ev.get("name")
|
|
899
|
+
if isinstance(name, str) and name:
|
|
900
|
+
return f"→ {name}"
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# --------------------------------------------------------------------------- #
|
|
905
|
+
# Offline / deterministic backend
|
|
906
|
+
# --------------------------------------------------------------------------- #
|
|
907
|
+
Responder = Callable[[str, str, str, str, dict[str, Any]], str]
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class ScriptedProvider:
|
|
911
|
+
"""Deterministic, network-free backend.
|
|
912
|
+
|
|
913
|
+
Pass a custom ``responder(role, model, system, user, meta) -> str`` to script
|
|
914
|
+
exact outputs (tests), or rely on the built-in responder which derives a
|
|
915
|
+
coherent plan and per-task results from the inputs (offline demo).
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
def __init__(self, responder: Responder | None = None):
|
|
919
|
+
self.responder = responder or default_responder
|
|
920
|
+
self.calls: list[dict[str, Any]] = [] # observability for tests
|
|
921
|
+
self.stats = RunStats()
|
|
922
|
+
|
|
923
|
+
async def complete(
|
|
924
|
+
self,
|
|
925
|
+
*,
|
|
926
|
+
model: str,
|
|
927
|
+
system: str,
|
|
928
|
+
user: str,
|
|
929
|
+
agent: str | None = None,
|
|
930
|
+
json_mode: bool = False,
|
|
931
|
+
temperature: float = 0.2,
|
|
932
|
+
role: str | None = None,
|
|
933
|
+
meta: dict[str, Any] | None = None,
|
|
934
|
+
on_output: Callable[[str], None] | None = None, # accepted for interface parity; unused
|
|
935
|
+
) -> str:
|
|
936
|
+
role = role or "unknown"
|
|
937
|
+
meta = meta or {}
|
|
938
|
+
label = model or (agent or "") # human-readable target for responders/labels
|
|
939
|
+
self.calls.append(
|
|
940
|
+
{"role": role, "model": label, "agent": agent or "", "user": user, "meta": meta}
|
|
941
|
+
)
|
|
942
|
+
out = self.responder(role, label, system, user, meta)
|
|
943
|
+
self.stats.calls += 1
|
|
944
|
+
if json_mode:
|
|
945
|
+
extract_json(out) # validate determinism early
|
|
946
|
+
return out
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def default_responder(role: str, model: str, system: str, user: str, meta: dict[str, Any]) -> str:
|
|
950
|
+
"""Built-in deterministic responses, one branch per role."""
|
|
951
|
+
if role == "planner":
|
|
952
|
+
return _scripted_plan_json(user, meta)
|
|
953
|
+
if role == "phase_define":
|
|
954
|
+
task = meta.get("task_title", "Task")
|
|
955
|
+
return (
|
|
956
|
+
f"# Work definition\n\n## Objective\nDeliver: {task}\n\n"
|
|
957
|
+
"## Steps\n1. Analyze the requirement.\n2. Implement the change.\n"
|
|
958
|
+
"3. Self-check the result.\n\n## Acceptance\n- Output addresses the objective.\n"
|
|
959
|
+
)
|
|
960
|
+
if role == "task_exec":
|
|
961
|
+
task = meta.get("task_title", "Task")
|
|
962
|
+
phase = meta.get("phase_title", "Phase")
|
|
963
|
+
return (
|
|
964
|
+
f"# Result: {task}\n\n_Phase: {phase} · model: {model}_\n\n"
|
|
965
|
+
f"Completed work for **{task}**. (Deterministic offline output.)\n\n"
|
|
966
|
+
"## Notes\n- Implemented per the work definition.\n- No blockers.\n"
|
|
967
|
+
)
|
|
968
|
+
if role == "phase_summary":
|
|
969
|
+
phase = meta.get("phase_title", "Phase")
|
|
970
|
+
n = meta.get("task_count", 0)
|
|
971
|
+
return (
|
|
972
|
+
f"# Phase summary: {phase}\n\nCompleted {n} task(s).\n\n"
|
|
973
|
+
"## Outcome\nAll task objectives met.\n\n## For the main orchestrator\n"
|
|
974
|
+
f"Phase '{phase}' is done; docs can be updated accordingly.\n"
|
|
975
|
+
)
|
|
976
|
+
if role == "main_kickoff":
|
|
977
|
+
goal = meta.get("goal", "")
|
|
978
|
+
return f"# Project overview\n\n**Goal:** {goal}\n\nExecution started.\n\n## Progress log\n"
|
|
979
|
+
if role == "main_integrate":
|
|
980
|
+
phase = meta.get("phase_title", "Phase")
|
|
981
|
+
return f"- Integrated phase **{phase}**: objectives met, docs updated.\n"
|
|
982
|
+
if role == "main_finalize":
|
|
983
|
+
goal = meta.get("goal", "")
|
|
984
|
+
return f"# Final report\n\n**Goal:** {goal}\n\nAll phases complete. See per-phase summaries.\n"
|
|
985
|
+
if role == "map_arch":
|
|
986
|
+
return (
|
|
987
|
+
"# Architecture overview\n\n## Purpose\nDeterministic offline mapping output.\n\n"
|
|
988
|
+
"## Module / component map\n| Path | Responsibility |\n| --- | --- |\n"
|
|
989
|
+
"| (scripted) | (offline backend) |\n\n## Risks / unknowns\n- Scripted output.\n"
|
|
990
|
+
)
|
|
991
|
+
if role == "map_prd":
|
|
992
|
+
return (
|
|
993
|
+
"# PRD (reverse-engineered)\n\n> Review and edit before planning.\n\n"
|
|
994
|
+
"## Summary\nDeterministic offline PRD output.\n\n"
|
|
995
|
+
"## Current Features & Capabilities\n- Offline scripted capability.\n\n"
|
|
996
|
+
"## Open questions & assumptions\n- All inferences are scripted placeholders.\n"
|
|
997
|
+
)
|
|
998
|
+
return f"[scripted:{role}] {user[:120]}"
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _scripted_plan_json(goal: str, meta: dict[str, Any]) -> str:
|
|
1002
|
+
models = meta.get("models", {})
|
|
1003
|
+
phase_model = models.get("phase", "scripted/phase")
|
|
1004
|
+
task_model = models.get("task", "scripted/task")
|
|
1005
|
+
goal_line = goal.strip().splitlines()[0] if goal.strip() else "the project"
|
|
1006
|
+
plan = {
|
|
1007
|
+
"goal": goal_line,
|
|
1008
|
+
"phases": [
|
|
1009
|
+
{
|
|
1010
|
+
"id": "p1",
|
|
1011
|
+
"title": "Foundation",
|
|
1012
|
+
"objective": f"Establish the groundwork for {goal_line}.",
|
|
1013
|
+
"runner": phase_model,
|
|
1014
|
+
"tasks": [
|
|
1015
|
+
{
|
|
1016
|
+
"id": "p1-t1",
|
|
1017
|
+
"title": "Define scope and structure",
|
|
1018
|
+
"description": "Lay out the structure needed for the goal.",
|
|
1019
|
+
"runner": task_model,
|
|
1020
|
+
"depends_on": [],
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
"id": "p1-t2",
|
|
1024
|
+
"title": "Implement core",
|
|
1025
|
+
"description": "Build the core pieces.",
|
|
1026
|
+
"runner": task_model,
|
|
1027
|
+
"depends_on": ["p1-t1"],
|
|
1028
|
+
},
|
|
1029
|
+
],
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
"id": "p2",
|
|
1033
|
+
"title": "Finish",
|
|
1034
|
+
"objective": f"Complete and verify {goal_line}.",
|
|
1035
|
+
"runner": phase_model,
|
|
1036
|
+
"tasks": [
|
|
1037
|
+
{
|
|
1038
|
+
"id": "p2-t1",
|
|
1039
|
+
"title": "Verify and document",
|
|
1040
|
+
"description": "Validate the result and write docs.",
|
|
1041
|
+
"runner": task_model,
|
|
1042
|
+
"depends_on": ["p1-t2"], # cross-phase: builds on phase 1's core
|
|
1043
|
+
}
|
|
1044
|
+
],
|
|
1045
|
+
},
|
|
1046
|
+
],
|
|
1047
|
+
}
|
|
1048
|
+
return json.dumps(plan, indent=2)
|