Commit
add optional budget cap that degrades to early synthesis
commit
a2d9bac
10 changed files with +118 and −3
Jump to a changed file
- src/components/debate/debate-console.tsx +13 −1
- src/components/debate/new-debate.tsx +29 −1
- src/core/config.ts +1 −0
- src/core/convergence.ts +1 −1
- src/core/events.ts +7 −0
- src/core/orchestrator.test.ts +26 −0
- src/core/orchestrator.ts +13 −0
- src/core/schemas.ts +1 −0
- src/core/types.ts +6 −0
- 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 | } |