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/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)