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/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Roundtable-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RoundtableError(Exception):
|
|
7
|
+
"""Base class for expected, user-facing roundtable errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskFailed(RoundtableError):
|
|
11
|
+
"""A task exhausted its retries or failed validation.
|
|
12
|
+
|
|
13
|
+
Raised inside ``_run_task`` and caught per-task in ``_schedule`` so that
|
|
14
|
+
dependent tasks can be skipped and the phase can be marked failed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, task_id: str, detail: str = ""):
|
|
18
|
+
self.task_id = task_id
|
|
19
|
+
self.detail = detail
|
|
20
|
+
super().__init__(f"task {task_id!r} failed: {detail}" if detail else f"task {task_id!r} failed")
|
roundtable/insights.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Live state + insights derived from ``plan.json`` and the run event log.
|
|
2
|
+
|
|
3
|
+
``build_state`` is a pure read over the on-disk source of truth, so any number of
|
|
4
|
+
viewers (the web dashboard, the terminal watch, a script hitting ``/api/state``)
|
|
5
|
+
can poll it without touching the running engine. ``render_text`` is the terminal
|
|
6
|
+
rendering. Insights: progress, what each agent is doing *now*, per-agent task
|
|
7
|
+
counts/time, and task durations paired from ``task_started``/``task_done`` events.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as _dt
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .models import Status
|
|
16
|
+
from .store import Store
|
|
17
|
+
|
|
18
|
+
_MARK = {"done": "x", "in_progress": "~", "pending": " ", "failed": "!", "skipped": "-", "waiting": "?"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse(ts: str | None) -> _dt.datetime | None:
|
|
22
|
+
if not ts:
|
|
23
|
+
return None
|
|
24
|
+
try:
|
|
25
|
+
return _dt.datetime.fromisoformat(ts)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fmt_dur(seconds: float | None) -> str:
|
|
31
|
+
if seconds is None:
|
|
32
|
+
return "—"
|
|
33
|
+
seconds = max(0, int(seconds))
|
|
34
|
+
h, rem = divmod(seconds, 3600)
|
|
35
|
+
m, s = divmod(rem, 60)
|
|
36
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_state(store: Store, *, event_limit: int = 40) -> dict[str, Any]:
|
|
40
|
+
"""Snapshot of the run: totals, current activity, per-agent + timing insights."""
|
|
41
|
+
if not store.has_plan():
|
|
42
|
+
return {"exists": False, "events": []}
|
|
43
|
+
|
|
44
|
+
plan = store.load_plan()
|
|
45
|
+
events = store.read_events()
|
|
46
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
47
|
+
|
|
48
|
+
# Pair task_started/task_done by task_id for start times + durations.
|
|
49
|
+
started_at: dict[str, str] = {}
|
|
50
|
+
duration: dict[str, float] = {}
|
|
51
|
+
for e in events:
|
|
52
|
+
tid = e.get("task_id")
|
|
53
|
+
if not tid:
|
|
54
|
+
continue
|
|
55
|
+
if e.get("type") == "task_started":
|
|
56
|
+
started_at[tid] = e.get("ts", "")
|
|
57
|
+
elif e.get("type") == "task_done" and tid in started_at:
|
|
58
|
+
a, b = _parse(started_at[tid]), _parse(e.get("ts"))
|
|
59
|
+
if a and b:
|
|
60
|
+
duration[tid] = (b - a).total_seconds()
|
|
61
|
+
|
|
62
|
+
totals = {"phases": len(plan.phases), "tasks": 0,
|
|
63
|
+
"done": 0, "in_progress": 0, "pending": 0, "failed": 0, "skipped": 0, "waiting": 0}
|
|
64
|
+
by_agent: dict[str, dict[str, float]] = {}
|
|
65
|
+
durations: list[tuple[str, float]] = []
|
|
66
|
+
now_running: list[dict[str, Any]] = []
|
|
67
|
+
phases_out: list[dict[str, Any]] = []
|
|
68
|
+
|
|
69
|
+
for ph in plan.phases:
|
|
70
|
+
tasks_out = []
|
|
71
|
+
for t in ph.tasks:
|
|
72
|
+
totals["tasks"] += 1
|
|
73
|
+
totals[t.status.value] = totals.get(t.status.value, 0) + 1
|
|
74
|
+
dur = duration.get(t.id)
|
|
75
|
+
if dur is not None:
|
|
76
|
+
durations.append((t.id, dur))
|
|
77
|
+
|
|
78
|
+
key = t.runner.agent or t.runner.model or "?"
|
|
79
|
+
agg = by_agent.setdefault(key, {"tasks": 0, "total_s": 0.0})
|
|
80
|
+
if t.status == Status.done:
|
|
81
|
+
agg["tasks"] += 1
|
|
82
|
+
if dur is not None:
|
|
83
|
+
agg["total_s"] += dur
|
|
84
|
+
|
|
85
|
+
entry = {
|
|
86
|
+
"id": t.id, "title": t.title, "runner": str(t.runner),
|
|
87
|
+
"agent": t.runner.agent, "model": t.runner.model,
|
|
88
|
+
"status": t.status.value, "duration_s": dur,
|
|
89
|
+
"depends_on": list(t.depends_on),
|
|
90
|
+
}
|
|
91
|
+
if t.status == Status.in_progress:
|
|
92
|
+
start = _parse(started_at.get(t.id))
|
|
93
|
+
entry["elapsed_s"] = (now - start).total_seconds() if start else None
|
|
94
|
+
now_running.append({"phase_id": ph.id, **entry})
|
|
95
|
+
tasks_out.append(entry)
|
|
96
|
+
|
|
97
|
+
done_n = sum(1 for t in ph.tasks if t.status == Status.done)
|
|
98
|
+
phases_out.append({
|
|
99
|
+
"id": ph.id, "index": ph.index, "title": ph.title, "objective": ph.objective,
|
|
100
|
+
"runner": str(ph.runner), "status": ph.status.value,
|
|
101
|
+
"tasks": tasks_out, "done": done_n, "total": len(ph.tasks),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
pct = round(100 * totals["done"] / totals["tasks"]) if totals["tasks"] else 0
|
|
105
|
+
avg = round(sum(d for _, d in durations) / len(durations), 1) if durations else None
|
|
106
|
+
slowest = max(durations, key=lambda x: x[1]) if durations else None
|
|
107
|
+
|
|
108
|
+
run_state = (
|
|
109
|
+
"done" if plan.status == Status.done
|
|
110
|
+
else "running" if plan.status == Status.in_progress
|
|
111
|
+
else "failed" if plan.status == Status.failed
|
|
112
|
+
else "pending"
|
|
113
|
+
)
|
|
114
|
+
for key, agg in by_agent.items():
|
|
115
|
+
agg["total_s"] = round(agg["total_s"], 1)
|
|
116
|
+
|
|
117
|
+
# Latest provider usage snapshot (emitted per task + at run end by the engine).
|
|
118
|
+
usage_evt = next((e for e in reversed(events) if e.get("type") == "usage"), None)
|
|
119
|
+
usage = None
|
|
120
|
+
if usage_evt:
|
|
121
|
+
usage = {
|
|
122
|
+
"calls": usage_evt.get("calls", 0),
|
|
123
|
+
"total_tokens": usage_evt.get("total_tokens", 0),
|
|
124
|
+
"prompt_tokens": usage_evt.get("prompt_tokens", 0),
|
|
125
|
+
"completion_tokens": usage_evt.get("completion_tokens", 0),
|
|
126
|
+
"total_duration_s": usage_evt.get("total_duration_s", 0.0),
|
|
127
|
+
"estimated": usage_evt.get("estimated", False),
|
|
128
|
+
"cost_usd": usage_evt.get("cost_usd", 0.0),
|
|
129
|
+
}
|
|
130
|
+
# Usage events feed the tile above; keep them out of the raw event stream.
|
|
131
|
+
feed = [e for e in events if e.get("type") != "usage"]
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"exists": True,
|
|
135
|
+
"goal": plan.goal,
|
|
136
|
+
"approved": plan.approved,
|
|
137
|
+
"status": run_state,
|
|
138
|
+
"started_at": next((e.get("ts") for e in events if e.get("type") == "run_started"), None),
|
|
139
|
+
"updated_at": events[-1].get("ts") if events else None,
|
|
140
|
+
"now": now_running,
|
|
141
|
+
"totals": {**totals, "percent": pct},
|
|
142
|
+
"phases": phases_out,
|
|
143
|
+
"by_agent": by_agent,
|
|
144
|
+
"timings": {
|
|
145
|
+
"avg_task_s": avg,
|
|
146
|
+
"slowest": ({"task_id": slowest[0], "duration_s": round(slowest[1], 1)}
|
|
147
|
+
if slowest else None),
|
|
148
|
+
},
|
|
149
|
+
"usage": usage,
|
|
150
|
+
"events": list(reversed(feed[-event_limit:])),
|
|
151
|
+
"generated_at": now.isoformat(timespec="seconds"),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_text(state: dict[str, Any], *, width: int = 64) -> str:
|
|
156
|
+
"""Compact terminal rendering of a state snapshot."""
|
|
157
|
+
if not state.get("exists"):
|
|
158
|
+
return "no plan yet — run `roundtable plan` first"
|
|
159
|
+
|
|
160
|
+
t = state["totals"]
|
|
161
|
+
badge = {"running": "● running", "done": "✓ done",
|
|
162
|
+
"failed": "✗ failed", "pending": "· pending"}.get(state["status"], state["status"])
|
|
163
|
+
filled = round((width - 2) * t["percent"] / 100)
|
|
164
|
+
bar = "█" * filled + "░" * (width - 2 - filled)
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
f"Roundtable {badge} {t['done']}/{t['tasks']} tasks · {t['percent']}%",
|
|
168
|
+
f"Goal: {state['goal']}",
|
|
169
|
+
f"[{bar}]",
|
|
170
|
+
"",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
if state["now"]:
|
|
174
|
+
for n in state["now"]:
|
|
175
|
+
lines.append(f"▶ NOW {n['id']} {n['title']!r}")
|
|
176
|
+
lines.append(f" {n['runner']} · running {fmt_dur(n.get('elapsed_s'))}")
|
|
177
|
+
else:
|
|
178
|
+
idle = "done" if state["status"] == "done" else "idle"
|
|
179
|
+
lines.append(f"▶ NOW ({idle})")
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
lines.append("Phases")
|
|
183
|
+
for ph in state["phases"]:
|
|
184
|
+
mark = _MARK.get(ph["status"], " ")
|
|
185
|
+
lines.append(f" [{mark}] {ph['index']} {ph['title']} {ph['done']}/{ph['total']} ({ph['runner']})")
|
|
186
|
+
lines.append("")
|
|
187
|
+
|
|
188
|
+
if state["by_agent"]:
|
|
189
|
+
ba = " · ".join(
|
|
190
|
+
f"{k} {v['tasks']}" + (f"/{fmt_dur(v['total_s'])}" if v["total_s"] else "")
|
|
191
|
+
for k, v in sorted(state["by_agent"].items())
|
|
192
|
+
)
|
|
193
|
+
lines.append(f"by agent: {ba}")
|
|
194
|
+
tim = state["timings"]
|
|
195
|
+
if tim["avg_task_s"] is not None:
|
|
196
|
+
slow = tim["slowest"]
|
|
197
|
+
extra = f" · slowest {slow['task_id']} {fmt_dur(slow['duration_s'])}" if slow else ""
|
|
198
|
+
lines.append(f"avg task {fmt_dur(tim['avg_task_s'])}{extra}")
|
|
199
|
+
|
|
200
|
+
u = state.get("usage")
|
|
201
|
+
if u:
|
|
202
|
+
note = " (est)" if u.get("estimated") else ""
|
|
203
|
+
cost = f" · ${u['cost_usd']:.4f}" if u.get("cost_usd") else ""
|
|
204
|
+
lines.append(
|
|
205
|
+
f"usage: {u['total_tokens']:,} tokens"
|
|
206
|
+
f" ({u['prompt_tokens']:,} in / {u['completion_tokens']:,} out)"
|
|
207
|
+
f" · {u['calls']} calls{cost}{note}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return "\n".join(lines)
|