profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM

Commit

add optional budget cap that degrades to early synthesis

commit a2d9bac

10 changed files with +118 and −3

Jump to a changed file
  1. src/components/debate/debate-console.tsx +13 −1
  2. src/components/debate/new-debate.tsx +29 −1
  3. src/core/config.ts +1 −0
  4. src/core/convergence.ts +1 −1
  5. src/core/events.ts +7 −0
  6. src/core/orchestrator.test.ts +26 −0
  7. src/core/orchestrator.ts +13 −0
  8. src/core/schemas.ts +1 −0
  9. src/core/types.ts +6 −0
  10. src/lib/debate-view.ts +21 −0
modified src/components/debate/debate-console.tsx +13 −1
@@ -1,6 +1,6 @@
1 1 'use client';
2 2
3 -import { AlertTriangle, CircleCheckBig, Scale } from 'lucide-react';
3 +import { AlertTriangle, CircleCheckBig, PiggyBank, Scale } from 'lucide-react';
4 4 import { CostBreakdown } from '@/components/debate/cost-breakdown';
5 5 import { CostMeter } from '@/components/debate/cost-meter';
6 6 import { CritiqueMatrix } from '@/components/debate/critique-matrix';
@@ -16,6 +16,7 @@import { Progress } from '@/components/ui/progress';
16 16 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
17 17 import type { AnswerRecord } from '@/core/types';
18 18 import { currentAnswers, type DebateView } from '@/lib/debate-view';
19 +import { formatUsd } from '@/lib/utils';
19 20
20 21 export function DebateConsole({ view, showActions = true }: { view: DebateView; showActions?: boolean }) {
21 22 const answers = currentAnswers(view);
@@ -50,6 +51,17 @@export function DebateConsole({ view, showActions = true }: { view: DebateView;
50 51 </div>
51 52 )}
52 53
54 + {view.budgetReached && (
55 + <div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
56 + <PiggyBank className="h-4 w-4 text-amber-500" />
57 + <span>
58 + Budget cap reached after round {view.budgetReached.round} (spent{' '}
59 + {formatUsd(view.budgetReached.totalCostUsd)} of a {formatUsd(view.budgetReached.maxCostUsd)} cap);
60 + remaining rounds were skipped and the chairman synthesized what existed.
61 + </span>
62 + </div>
63 + )}
64 +
53 65 <StageTimeline view={view} />
54 66 </div>
55 67
modified src/components/debate/new-debate.tsx +29 −1
@@ -46,6 +46,7 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
46 46 const [temperature, setTemperature] = useState(0.7);
47 47 const [presets, setPresets] = useState<Preset[]>([]);
48 48 const [presetName, setPresetName] = useState('');
49 + const [maxCost, setMaxCost] = useState('');
49 50 const [keyDialogOpen, setKeyDialogOpen] = useState(false);
50 51 const [needsKey, setNeedsKey] = useState(false);
51 52 const [prefilled, setPrefilled] = useState(false);
@@ -108,6 +109,8 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
108 109 }, []);
109 110
110 111 const providerConflict = chairman && council.length > 0 && chairmanSharesProvider(chairman, council);
112 + const maxCostUsd = Number.parseFloat(maxCost);
113 + const capValid = maxCost.trim() === '' || (Number.isFinite(maxCostUsd) && maxCostUsd > 0);
111 114 const startBlocker =
112 115 question.trim().length < 3
113 116 ? 'Write a question to get started'
@@ -117,7 +120,9 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
117 120 ? 'A council holds at most 6 models'
118 121 : !chairman
119 122 ? 'Pick a chairman to write the final verdict'
120 - : null;
123 + : !capValid
124 + ? 'Fix the budget cap (positive amount or empty)'
125 + : null;
121 126 const canStart = startBlocker === null;
122 127 const running = phase === 'connecting' || phase === 'streaming';
123 128
@@ -196,6 +201,7 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
196 201 convergenceThreshold: threshold,
197 202 temperature,
198 203 perModelTimeoutMs: 90_000,
204 + ...(capValid && maxCost.trim() !== '' ? { maxCostUsd } : {}),
199 205 });
200 206
201 207 if (phase === 'idle') {
@@ -308,6 +314,28 @@export function NewDebate({ fromDebateId }: { fromDebateId?: string }) {
308 314 hint="Low keeps answers focused and consistent. High makes them more varied and creative."
309 315 />
310 316
317 + <div className="space-y-1.5">
318 + <Label htmlFor="rt-budget">Budget cap (optional)</Label>
319 + <div className="flex items-center gap-2">
320 + <span className="text-sm text-muted-foreground">$</span>
321 + <Input
322 + id="rt-budget"
323 + inputMode="decimal"
324 + placeholder="no cap"
325 + value={maxCost}
326 + onChange={(e) => setMaxCost(e.target.value)}
327 + className="h-8 max-w-[8rem]"
328 + />
329 + </div>
330 + <p className="text-xs text-muted-foreground">
331 + If spend crosses this, remaining rounds are skipped and the chairman synthesizes what exists, so you
332 + still get an answer for the money already spent.
333 + </p>
334 + {!capValid && (
335 + <p className="text-xs text-destructive">Enter a positive dollar amount, or leave empty for no cap.</p>
336 + )}
337 + </div>
338 +
311 339 {presets.length > 0 && (
312 340 <div className="space-y-1.5">
313 341 <Label>Presets</Label>
modified src/core/config.ts +1 −0
@@ -28,6 +28,7 @@export function resolveDebateConfig(input: DebateConfigInput): DebateConfig {
28 28 convergenceThreshold: input.convergenceThreshold,
29 29 temperature: input.temperature,
30 30 perModelTimeoutMs: input.perModelTimeoutMs,
31 + ...(input.maxCostUsd !== undefined ? { maxCostUsd: input.maxCostUsd } : {}),
31 32 };
32 33 }
33 34
modified src/core/convergence.ts +1 −1
@@ -6,7 +6,7 @@
6 6 * by the assessor model and the current health of the council.
7 7 */
8 8
9 -export type StopReason = 'converged' | 'max_rounds' | 'insufficient_models' | 'continue';
9 +export type StopReason = 'converged' | 'max_rounds' | 'insufficient_models' | 'budget' | 'continue';
10 10
11 11 export interface StopDecision {
12 12 stop: boolean;
modified src/core/events.ts +7 −0
@@ -61,6 +61,13 @@export type DebateEvent =
61 61 }
62 62 | { type: 'synthesis_completed'; record: SynthesisRecord }
63 63 | { type: 'provenance_completed'; record: ProvenanceRecord }
64 + | {
65 + /** Spend crossed the configured cap; skipping remaining rounds. */
66 + type: 'budget_reached';
67 + round: number;
68 + totalCostUsd: number;
69 + maxCostUsd: number;
70 + }
64 71 | {
65 72 type: 'cost_update';
66 73 totalCostUsd: number;
modified src/core/orchestrator.test.ts +26 −0
@@ -173,6 +173,32 @@describe('runDebate - JSON repair', () => {
173 173 });
174 174 });
175 175
176 +describe('runDebate - budget cap', () => {
177 + it('skips remaining rounds and synthesizes when spend crosses the cap', async () => {
178 + // Round-0 answers alone cost more than this, so no cycle should run.
179 + const { result, of } = await run(makeConfig({ maxCostUsd: 0.000001 }));
180 + expect(result.status).toBe('completed');
181 + expect(result.rounds).toHaveLength(0);
182 + expect(result.synthesis).not.toBeNull();
183 +
184 + const budget = of('budget_reached');
185 + expect(budget).toHaveLength(1);
186 + expect(budget[0]!.round).toBe(0);
187 + expect(budget[0]!.totalCostUsd).toBeGreaterThanOrEqual(budget[0]!.maxCostUsd);
188 + });
189 +
190 + it('never fires without a cap configured', async () => {
191 + const { of } = await run(makeConfig());
192 + expect(of('budget_reached')).toHaveLength(0);
193 + });
194 +
195 + it('runs rounds normally while under the cap', async () => {
196 + const { result, of } = await run(makeConfig({ maxCostUsd: 100 }));
197 + expect(result.rounds.length).toBeGreaterThan(0);
198 + expect(of('budget_reached')).toHaveLength(0);
199 + });
200 +});
201 +
176 202 describe('runDebate - provenance audit', () => {
177 203 it('traces final-answer claims to council members and flags chairman additions', async () => {
178 204 const { result } = await run(makeConfig());
modified src/core/orchestrator.ts +13 −0
@@ -253,6 +253,19 @@export async function runDebate(
253 253 // === Rounds 1..maxRounds: critique -> revision -> convergence =========
254 254 let stopReason: StopReason = 'max_rounds';
255 255 for (let round = 1; round <= config.maxRounds; round++) {
256 + // Spend cap: skip remaining rounds and let the chairman synthesize what
257 + // exists - a cheaper answer instead of a wasted debate.
258 + if (config.maxCostUsd !== undefined && state.totals.costUsd >= config.maxCostUsd) {
259 + stopReason = 'budget';
260 + logger.warn('budget_reached', { round: round - 1, totalCostUsd: state.totals.costUsd, maxCostUsd: config.maxCostUsd });
261 + await emit({
262 + type: 'budget_reached',
263 + round: round - 1,
264 + totalCostUsd: state.totals.costUsd,
265 + maxCostUsd: config.maxCostUsd,
266 + });
267 + break;
268 + }
256 269 await emit({ type: 'round_started', round, kind: 'cycle' });
257 270
258 271 const critiques = await critiquePhase(round);
modified src/core/schemas.ts +1 −0
@@ -104,6 +104,7 @@export const debateConfigInputSchema = z
104 104 convergenceThreshold: z.number().int().min(0).max(100).default(85),
105 105 temperature: z.number().min(0).max(2).default(0.7),
106 106 perModelTimeoutMs: z.number().int().min(5_000).max(600_000).default(90_000),
107 + maxCostUsd: z.number().positive().max(1_000).optional(),
107 108 })
108 109 .refine((c) => new Set(c.models).size === c.models.length, {
109 110 message: 'Council models must be unique',
modified src/core/types.ts +6 −0
@@ -42,6 +42,12 @@export interface DebateConfig {
42 42 temperature: number;
43 43 /** Per-model, per-stage wall-clock budget in ms before the call is dropped. */
44 44 perModelTimeoutMs: number;
45 + /**
46 + * Optional spend ceiling in USD. When accumulated cost crosses it, remaining
47 + * critique/revision rounds are skipped and the debate goes straight to
48 + * synthesis - a cheaper answer instead of no answer.
49 + */
50 + maxCostUsd?: number;
45 51 }
46 52
47 53 export type DebateStatus =
modified src/lib/debate-view.ts +21 −0
@@ -51,6 +51,8 @@export interface DebateView {
51 51 /** participantId -> the stage it is currently working on. */
52 52 working: Record<string, StageType>;
53 53 droppedParticipants: string[];
54 + /** Set when the spend cap cut the debate short of its configured rounds. */
55 + budgetReached: { round: number; totalCostUsd: number; maxCostUsd: number } | null;
54 56 error?: string;
55 57 }
56 58
@@ -74,6 +76,7 @@export function initialDebateView(question = ''): DebateView {
74 76 activeStage: null,
75 77 working: {},
76 78 droppedParticipants: [],
79 + budgetReached: null,
77 80 };
78 81 }
79 82
@@ -206,6 +209,12 @@export function applyEvent(prev: DebateView, event: DebateEvent): DebateView {
206 209 case 'provenance_completed':
207 210 return { ...prev, provenance: event.record, activeStage: null };
208 211
212 + case 'budget_reached':
213 + return {
214 + ...prev,
215 + budgetReached: { round: event.round, totalCostUsd: event.totalCostUsd, maxCostUsd: event.maxCostUsd },
216 + };
217 +
209 218 case 'debate_completed':
210 219 return {
211 220 ...prev,
@@ -251,6 +260,18 @@export function fromResult(result: DebateResult): DebateView {
251 260 activeStage: null,
252 261 working: {},
253 262 droppedParticipants: result.failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId),
263 + // Not persisted as an event; derivable: the cap was crossed and the debate
264 + // stopped short of its configured rounds.
265 + budgetReached:
266 + result.config.maxCostUsd !== undefined &&
267 + result.totals.costUsd >= result.config.maxCostUsd &&
268 + result.totals.rounds < result.config.maxRounds
269 + ? {
270 + round: result.totals.rounds,
271 + totalCostUsd: result.totals.costUsd,
272 + maxCostUsd: result.config.maxCostUsd,
273 + }
274 + : null,
254 275 ...(result.error ? { error: result.error } : {}),
255 276 };
256 277 }