|
9 | 9 |
|
10 | 10 | import Foundation |
11 | 11 |
|
12 | | -/// Per-category token breakdown for the context window, displayed in the |
13 | | -/// context budget hover popover. |
14 | | -public struct ContextTokenBreakdown: Equatable, Sendable { |
15 | | - public var systemPrompt: Int = 0 |
16 | | - public var memory: Int = 0 |
17 | | - public var tools: Int = 0 |
18 | | - public var conversation: Int = 0 |
19 | | - public var input: Int = 0 |
20 | | - public var output: Int = 0 |
| 12 | +/// Dynamic token breakdown for the context window, displayed in the |
| 13 | +/// context budget hover popover. Entries are derived from the composer's |
| 14 | +/// manifest sections rather than hardcoded fields. |
| 15 | +public struct ContextBreakdown: Equatable, Sendable { |
| 16 | + |
| 17 | + public struct Entry: Identifiable, Equatable, Sendable { |
| 18 | + public let id: String |
| 19 | + public let label: String |
| 20 | + public var tokens: Int |
| 21 | + public let tint: Tint |
| 22 | + } |
| 23 | + |
| 24 | + public enum Tint: String, Sendable { |
| 25 | + case purple, blue, orange, green, gray, cyan, teal, indigo |
| 26 | + } |
| 27 | + |
| 28 | + /// Prompt sections + tools |
| 29 | + public var context: [Entry] |
| 30 | + /// Conversation + input + output |
| 31 | + public var messages: [Entry] |
21 | 32 |
|
22 | 33 | public var total: Int { |
23 | | - systemPrompt + memory + tools + conversation + input + output |
| 34 | + context.reduce(0) { $0 + $1.tokens } + messages.reduce(0) { $0 + $1.tokens } |
24 | 35 | } |
25 | 36 |
|
26 | | - public static let zero = ContextTokenBreakdown() |
| 37 | + public var allEntries: [Entry] { context + messages } |
| 38 | + |
| 39 | + public static let zero = ContextBreakdown(context: [], messages: []) |
| 40 | + |
| 41 | + /// Tint for a given prompt section ID. |
| 42 | + static func tint(for sectionId: String) -> Tint { |
| 43 | + switch sectionId { |
| 44 | + case "base": return .purple |
| 45 | + case "workMode": return .indigo |
| 46 | + case "sandbox": return .teal |
| 47 | + case "memory": return .blue |
| 48 | + case "preflight": return .cyan |
| 49 | + case "skills": return .orange |
| 50 | + default: return .gray |
| 51 | + } |
| 52 | + } |
27 | 53 |
|
28 | | - /// Non-zero categories with their display metadata. |
29 | | - public var categories: [Category] { |
30 | | - Category.all(from: self).filter { $0.tokens > 0 } |
| 54 | + /// Build a breakdown from a `ComposedContext` with optional message token counts. |
| 55 | + static func from( |
| 56 | + context composed: ComposedContext, |
| 57 | + conversationTokens: Int = 0, |
| 58 | + inputTokens: Int = 0, |
| 59 | + outputTokens: Int = 0 |
| 60 | + ) -> ContextBreakdown { |
| 61 | + .from( |
| 62 | + manifest: composed.manifest, |
| 63 | + toolTokens: composed.toolTokens, |
| 64 | + conversationTokens: conversationTokens, |
| 65 | + inputTokens: inputTokens, |
| 66 | + outputTokens: outputTokens |
| 67 | + ) |
31 | 68 | } |
32 | 69 |
|
33 | | - public struct Category: Identifiable { |
34 | | - public let label: String |
35 | | - public let tokens: Int |
36 | | - public let tint: Tint |
37 | | - public var id: String { label } |
| 70 | + /// Build a breakdown from a manifest + tool tokens. |
| 71 | + public static func from( |
| 72 | + manifest: PromptManifest, |
| 73 | + toolTokens: Int = 0, |
| 74 | + conversationTokens: Int = 0, |
| 75 | + inputTokens: Int = 0, |
| 76 | + outputTokens: Int = 0 |
| 77 | + ) -> ContextBreakdown { |
| 78 | + var ctx: [Entry] = manifest.sections |
| 79 | + .filter { $0.estimatedTokens > 0 } |
| 80 | + .map { Entry(id: $0.id, label: $0.label, tokens: $0.estimatedTokens, tint: tint(for: $0.id)) } |
| 81 | + if toolTokens > 0 { |
| 82 | + ctx.append(Entry(id: "tools", label: "Tools", tokens: toolTokens, tint: .orange)) |
| 83 | + } |
38 | 84 |
|
39 | | - public enum Tint: String { |
40 | | - case purple, blue, orange, green, gray, cyan |
| 85 | + var msgs: [Entry] = [] |
| 86 | + if conversationTokens > 0 { |
| 87 | + msgs.append(Entry(id: "conversation", label: "Conversation", tokens: conversationTokens, tint: .gray)) |
41 | 88 | } |
| 89 | + if inputTokens > 0 { msgs.append(Entry(id: "input", label: "Input", tokens: inputTokens, tint: .cyan)) } |
| 90 | + if outputTokens > 0 { msgs.append(Entry(id: "output", label: "Output", tokens: outputTokens, tint: .green)) } |
42 | 91 |
|
43 | | - static func all(from b: ContextTokenBreakdown) -> [Category] { |
44 | | - [ |
45 | | - Category(label: "System Prompt", tokens: b.systemPrompt, tint: .purple), |
46 | | - Category(label: "Memory", tokens: b.memory, tint: .blue), |
47 | | - Category(label: "Tools", tokens: b.tools, tint: .orange), |
48 | | - Category(label: "Conversation", tokens: b.conversation, tint: .gray), |
49 | | - Category(label: "Input", tokens: b.input, tint: .cyan), |
50 | | - Category(label: "Output", tokens: b.output, tint: .green), |
51 | | - ] |
| 92 | + return ContextBreakdown(context: ctx, messages: msgs) |
| 93 | + } |
| 94 | + |
| 95 | + /// Update the token count for an entry by ID, or append it if not present. |
| 96 | + public mutating func setTokens( |
| 97 | + for id: String, |
| 98 | + in group: WritableKeyPath<ContextBreakdown, [Entry]>, |
| 99 | + tokens: Int, |
| 100 | + label: String = "", |
| 101 | + tint: Tint = .gray |
| 102 | + ) { |
| 103 | + if let idx = self[keyPath: group].firstIndex(where: { $0.id == id }) { |
| 104 | + let existing = self[keyPath: group][idx] |
| 105 | + self[keyPath: group][idx] = Entry(id: id, label: existing.label, tokens: tokens, tint: existing.tint) |
| 106 | + } else if tokens > 0 { |
| 107 | + self[keyPath: group].append(Entry(id: id, label: label, tokens: tokens, tint: tint)) |
52 | 108 | } |
53 | 109 | } |
54 | 110 | } |
@@ -344,47 +400,45 @@ public struct ContextBudgetManager: Sendable { |
344 | 400 | /// Tracks the active request's token breakdown during streaming/execution. |
345 | 401 | /// |
346 | 402 | /// Both `ChatSession` and `WorkSession` own an instance. The lifecycle is: |
347 | | -/// 1. `snapshot()` — after preflight, captures the actual system prompt, memory, and tool tokens |
348 | | -/// 2. `updateConversation()` — at each agent-loop iteration, updates conversation tokens |
349 | | -/// 3. `activeBreakdown()` — O(1) read returning the snapshot + live output tokens |
| 403 | +/// 1. `snapshot()` — captures context from ComposedContext or manifest |
| 404 | +/// 2. `updateConversation()` — at each agent-loop iteration, updates conversation + output tokens |
| 405 | +/// 3. `activeBreakdown()` — O(1) read returning the snapshot with live message tokens |
350 | 406 | /// 4. `clear()` — on completion/error/cancellation |
351 | 407 | @MainActor |
352 | 408 | final class ContextBudgetTracker { |
353 | | - private var breakdown: ContextTokenBreakdown? |
| 409 | + private var breakdown: ContextBreakdown? |
354 | 410 | private var cumulativeOutputTokens: Int = 0 |
355 | 411 |
|
356 | | - /// Snapshot the fixed components of the actual request context. |
357 | | - func snapshot(systemPromptChars: Int, memoryTokens: Int, toolTokens: Int) { |
358 | | - var bd = ContextTokenBreakdown() |
359 | | - bd.systemPrompt = max(1, systemPromptChars / ContextBudgetManager.charsPerToken) |
360 | | - bd.memory = memoryTokens |
361 | | - bd.tools = toolTokens |
362 | | - breakdown = bd |
| 412 | + /// Snapshot from a ComposedContext (chat path). |
| 413 | + func snapshot(context: ComposedContext) { |
| 414 | + breakdown = .from(context: context) |
| 415 | + } |
| 416 | + |
| 417 | + /// Snapshot from a manifest + tool tokens (work path where ComposedContext isn't available). |
| 418 | + func snapshot(manifest: PromptManifest, toolTokens: Int) { |
| 419 | + breakdown = .from(manifest: manifest, toolTokens: toolTokens) |
363 | 420 | } |
364 | 421 |
|
365 | 422 | /// Update conversation tokens at each agent-loop iteration start. |
366 | | - /// Accumulates the finished turn's output before starting a new iteration |
367 | | - /// so the `output` category reflects total model output across all turns. |
368 | 423 | func updateConversation(tokens: Int, finishedOutputTurn: ChatTurn? = nil) { |
369 | 424 | if let turn = finishedOutputTurn, turn.role == .assistant { |
370 | 425 | cumulativeOutputTokens += ContextBudgetManager.estimateOutputTokens(for: turn) |
371 | 426 | } |
372 | | - breakdown?.conversation = tokens |
| 427 | + breakdown?.setTokens(for: "conversation", in: \.messages, tokens: tokens, label: "Conversation", tint: .gray) |
373 | 428 | } |
374 | 429 |
|
375 | | - /// Returns the snapshot with live output tokens appended, or nil if |
376 | | - /// no snapshot is active (caller falls back to full recomputation). |
377 | | - func activeBreakdown(isActive: Bool, outputTurn: ChatTurn?) -> ContextTokenBreakdown? { |
| 430 | + /// Returns the snapshot with live output tokens, or nil if no snapshot is active. |
| 431 | + func activeBreakdown(isActive: Bool, outputTurn: ChatTurn?) -> ContextBreakdown? { |
378 | 432 | guard var bd = breakdown, isActive else { return nil } |
379 | 433 | var currentTurnOutput = 0 |
380 | 434 | if let turn = outputTurn, turn.role == .assistant { |
381 | 435 | currentTurnOutput = ContextBudgetManager.estimateOutputTokens(for: turn) |
382 | 436 | } |
383 | | - bd.output = cumulativeOutputTokens + currentTurnOutput |
| 437 | + let totalOutput = cumulativeOutputTokens + currentTurnOutput |
| 438 | + bd.setTokens(for: "output", in: \.messages, tokens: totalOutput, label: "Output", tint: .green) |
384 | 439 | return bd |
385 | 440 | } |
386 | 441 |
|
387 | | - /// Clear the active snapshot. Next read falls back to full recomputation. |
388 | 442 | func clear() { |
389 | 443 | breakdown = nil |
390 | 444 | cumulativeOutputTokens = 0 |
|
0 commit comments