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 ADDED
@@ -0,0 +1,10 @@
1
+ """roundtable: a multi-LLM planning and orchestration tool.
2
+
3
+ Flow: Planner LLM -> approved Plan (phases -> tasks) -> Main
4
+ Orchestrator drives phases; each Phase Orchestrator (fresh context) dispatches
5
+ Task Agents, summarizes, and reports to Main, which maintains the docs.
6
+
7
+ Every role's model is choosable via config or the plan manifest.
8
+ """
9
+
10
+ __version__ = "0.4.0"
roundtable/agents.py ADDED
@@ -0,0 +1,218 @@
1
+ """The agent roles: Planner, Main Orchestrator, Phase Orchestrator, Task Agent.
2
+
3
+ Each agent is a thin wrapper binding a *choosable* model to a role. Control flow
4
+ lives in the engine; these classes only turn inputs into the LLM call for their
5
+ role and return the produced text (Markdown, or JSON for the planner).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable
11
+
12
+ from . import prompts
13
+ from .llm import LLMProvider, extract_json
14
+ from .models import AgentRef, Phase, Plan, Task
15
+ from .prompts import render_prompt
16
+
17
+
18
+ def _truncate(text: str, limit: int = 4000) -> str:
19
+ return text if len(text) <= limit else text[:limit] + "\n...[truncated]..."
20
+
21
+
22
+ def _models_meta(defaults: dict[str, AgentRef]) -> dict[str, str]:
23
+ """Stringify role -> AgentRef so the (scripted) backend gets plain strings."""
24
+ return {k: str(v) for k, v in defaults.items()}
25
+
26
+
27
+ class Planner:
28
+ def __init__(self, provider: LLMProvider, ref: AgentRef, temperature: float = 0.2):
29
+ self.provider, self.ref, self.temperature = provider, ref, temperature
30
+
31
+ async def create_plan(
32
+ self, goal: str, allowed_models: list[str], defaults: dict[str, AgentRef]
33
+ ) -> Plan:
34
+ user = (
35
+ f"GOAL:\n{goal}\n\n"
36
+ f"ALLOWED (agent:model): {', '.join(allowed_models)}\n"
37
+ f"ROLE DEFAULTS: phase orchestrator -> {defaults.get('phase')}, "
38
+ f"task agent -> {defaults.get('task')}\n\n"
39
+ "Produce the plan JSON now."
40
+ )
41
+ raw = await self.provider.complete(
42
+ model=self.ref.model, agent=self.ref.agent, system=prompts.PLANNER_SYSTEM, user=user,
43
+ json_mode=True, temperature=self.temperature, role="planner",
44
+ meta={"models": _models_meta(defaults)},
45
+ )
46
+ return Plan.model_validate(extract_json(raw))
47
+
48
+ async def structure_plan(
49
+ self, existing: str, allowed_models: list[str], defaults: dict[str, AgentRef]
50
+ ) -> Plan:
51
+ """Convert an existing free-form plan / PRD into the roundtable schema."""
52
+ user = (
53
+ f"EXISTING PLAN / PRD:\n{existing}\n\n"
54
+ f"ALLOWED (agent:model): {', '.join(allowed_models)}\n"
55
+ f"ROLE DEFAULTS: phase orchestrator -> {defaults.get('phase')}, "
56
+ f"task agent -> {defaults.get('task')}\n\n"
57
+ "Convert it into the plan JSON now."
58
+ )
59
+ raw = await self.provider.complete(
60
+ model=self.ref.model, agent=self.ref.agent, system=prompts.PLAN_IMPORT_SYSTEM, user=user,
61
+ json_mode=True, temperature=self.temperature, role="planner",
62
+ meta={"models": _models_meta(defaults)},
63
+ )
64
+ return Plan.model_validate(extract_json(raw))
65
+
66
+
67
+ class Analyst:
68
+ """Maps an existing codebase: an architecture overview, then a PRD.
69
+
70
+ Both calls take the (already size-bounded) codebase digest from ``scan``.
71
+ The PRD call also gets the architecture overview for grounding.
72
+ """
73
+
74
+ def __init__(self, provider: LLMProvider, ref: AgentRef, temperature: float = 0.2):
75
+ self.provider, self.ref, self.temperature = provider, ref, temperature
76
+
77
+ async def architecture(self, digest: str) -> str:
78
+ user = f"CODEBASE DIGEST:\n{digest}\n\nWrite the architecture overview."
79
+ return await self.provider.complete(
80
+ model=self.ref.model, agent=self.ref.agent, system=prompts.MAP_ARCH_SYSTEM, user=user,
81
+ temperature=self.temperature, role="map_arch", meta={"digest_bytes": len(digest)},
82
+ )
83
+
84
+ async def prd(self, digest: str, architecture: str) -> str:
85
+ user = (
86
+ f"CODEBASE DIGEST:\n{digest}\n\n"
87
+ f"ARCHITECTURE OVERVIEW:\n{_truncate(architecture, 8000)}\n\n"
88
+ "Write the reverse-engineered PRD."
89
+ )
90
+ return await self.provider.complete(
91
+ model=self.ref.model, agent=self.ref.agent, system=prompts.MAP_PRD_SYSTEM, user=user,
92
+ temperature=self.temperature, role="map_prd", meta={"digest_bytes": len(digest)},
93
+ )
94
+
95
+
96
+ class MainOrchestrator:
97
+ def __init__(self, provider: LLMProvider, ref: AgentRef, temperature: float = 0.2):
98
+ self.provider, self.ref, self.temperature = provider, ref, temperature
99
+
100
+ async def kickoff(self, plan: Plan) -> str:
101
+ roadmap = "\n".join(f"- {p.index}. {p.title}: {p.objective}" for p in plan.phases)
102
+ user = f"GOAL:\n{plan.goal}\n\nPHASE ROADMAP:\n{roadmap}\n\nWrite the overview."
103
+ return await self.provider.complete(
104
+ model=self.ref.model, agent=self.ref.agent, system=prompts.MAIN_KICKOFF_SYSTEM, user=user,
105
+ temperature=self.temperature, role="main_kickoff", meta={"goal": plan.goal},
106
+ )
107
+
108
+ async def integrate_phase(self, goal: str, phase: Phase, summary: str) -> str:
109
+ """Main sees ONLY the phase summary here — never raw task transcripts."""
110
+ user = (
111
+ f"GOAL:\n{goal}\n\nCOMPLETED PHASE: {phase.index}. {phase.title}\n\n"
112
+ f"PHASE SUMMARY:\n{_truncate(summary)}\n\nWrite the progress entry."
113
+ )
114
+ return await self.provider.complete(
115
+ model=self.ref.model, agent=self.ref.agent, system=prompts.MAIN_INTEGRATE_SYSTEM, user=user,
116
+ temperature=self.temperature, role="main_integrate",
117
+ meta={"goal": goal, "phase_title": phase.title},
118
+ )
119
+
120
+ async def finalize(self, plan: Plan) -> str:
121
+ roadmap = "\n".join(f"- {p.index}. {p.title}" for p in plan.phases)
122
+ user = f"GOAL:\n{plan.goal}\n\nPHASES:\n{roadmap}\n\nWrite the final report."
123
+ return await self.provider.complete(
124
+ model=self.ref.model, agent=self.ref.agent, system=prompts.MAIN_FINALIZE_SYSTEM, user=user,
125
+ temperature=self.temperature, role="main_finalize", meta={"goal": plan.goal},
126
+ )
127
+
128
+
129
+ class PhaseOrchestrator:
130
+ """Instantiated fresh per phase (its context is discarded after summarize)."""
131
+
132
+ def __init__(self, provider: LLMProvider, ref: AgentRef, temperature: float = 0.2):
133
+ self.provider, self.ref, self.temperature = provider, ref, temperature
134
+
135
+ async def define_task(self, goal: str, phase: Phase, task: Task, *, project_context: str = "") -> str:
136
+ user = (
137
+ f"PROJECT GOAL:\n{goal}\n\nPHASE: {phase.title} — {phase.objective}\n\n"
138
+ f"TASK: {task.title}\nDESCRIPTION: {task.description}\n\n"
139
+ "Write the work definition."
140
+ )
141
+ system = render_prompt(prompts.PHASE_DEFINE_SYSTEM, project_context=project_context)
142
+ return await self.provider.complete(
143
+ model=self.ref.model, agent=self.ref.agent, system=system, user=user,
144
+ temperature=self.temperature, role="phase_define",
145
+ meta={"task_title": task.title, "phase_title": phase.title},
146
+ )
147
+
148
+ async def replan(
149
+ self,
150
+ phase: Phase,
151
+ failed_task: Task,
152
+ failure_output: str,
153
+ remaining: list[Task],
154
+ ) -> dict[str, str]:
155
+ """After a task failure, return updated descriptions for remaining tasks."""
156
+ remaining_lines = "\n".join(f"- [{t.id}] {t.title}: {t.description}" for t in remaining)
157
+ user = (
158
+ f"PHASE: {phase.title} — {phase.objective}\n\n"
159
+ f"FAILED TASK: [{failed_task.id}] {failed_task.title}\n"
160
+ f"FAILURE OUTPUT:\n{_truncate(failure_output, 1000)}\n\n"
161
+ f"REMAINING TASKS:\n{remaining_lines}\n\n"
162
+ "Return a JSON object mapping task_id to updated description for tasks "
163
+ "that need adjustment. Return {{}} if no changes are needed."
164
+ )
165
+ raw = await self.provider.complete(
166
+ model=self.ref.model, agent=self.ref.agent,
167
+ system=prompts.PHASE_REPLAN_SYSTEM, user=user,
168
+ json_mode=True, temperature=self.temperature, role="phase_replan",
169
+ meta={"phase_title": phase.title, "failed_task_id": failed_task.id},
170
+ )
171
+ try:
172
+ result = extract_json(raw)
173
+ except ValueError:
174
+ return {}
175
+ if not isinstance(result, dict): # a model may return a list; ignore it
176
+ return {}
177
+ return {k: v for k, v in result.items() if isinstance(k, str) and isinstance(v, str)}
178
+
179
+ async def summarize(self, phase: Phase, results: list[tuple[Task, str]]) -> str:
180
+ body = "\n\n".join(
181
+ f"### {t.title} [{t.id}]\n{_truncate(r, 1500)}" for t, r in results
182
+ )
183
+ user = (
184
+ f"PHASE: {phase.title} — {phase.objective}\n\n"
185
+ f"TASK RESULTS:\n{body}\n\nWrite the phase summary for the main orchestrator."
186
+ )
187
+ return await self.provider.complete(
188
+ model=self.ref.model, agent=self.ref.agent, system=prompts.PHASE_SUMMARY_SYSTEM, user=user,
189
+ temperature=self.temperature, role="phase_summary",
190
+ meta={"phase_title": phase.title, "task_count": len(results)},
191
+ )
192
+
193
+
194
+ class TaskAgent:
195
+ def __init__(self, provider: LLMProvider, ref: AgentRef, temperature: float = 0.2):
196
+ self.provider, self.ref, self.temperature = provider, ref, temperature
197
+
198
+ async def execute(
199
+ self, goal: str, phase: Phase, task: Task, task_def: str, deps: list[tuple[Task, str]],
200
+ *, project_context: str = "", on_output: Callable[[str], None] | None = None,
201
+ ) -> str:
202
+ deps_ctx = (
203
+ "\n\n".join(f"### Dependency {t.title} [{t.id}]\n{_truncate(r, 1200)}" for t, r in deps)
204
+ if deps
205
+ else "(none)"
206
+ )
207
+ user = (
208
+ f"PROJECT GOAL:\n{goal}\n\nPHASE: {phase.title}\n\n"
209
+ f"WORK DEFINITION:\n{task_def}\n\nUPSTREAM RESULTS:\n{deps_ctx}\n\n"
210
+ "Execute the task and produce the deliverable."
211
+ )
212
+ system = render_prompt(prompts.TASK_EXEC_SYSTEM, project_context=project_context)
213
+ return await self.provider.complete(
214
+ model=self.ref.model, agent=self.ref.agent, system=system, user=user,
215
+ temperature=self.temperature, role="task_exec",
216
+ meta={"task_title": task.title, "phase_title": phase.title},
217
+ on_output=on_output,
218
+ )