Skip to content

Commit b7b6f06

Browse files
Implement per-subagent cost attribution and surface credits in interactive UI
Moves credit display to subagent selector and detailed trace view, wired to backend cost events and client store; excludes .agents changes. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 7d83518 commit b7b6f06

File tree

9 files changed

+67
-33
lines changed

9 files changed

+67
-33
lines changed

backend/src/llm-apis/message-cost-tracker.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ async function sendCostResponseToClient(
420420
clientSessionId: string,
421421
userInputId: string,
422422
creditsUsed: number,
423+
agentId?: string,
423424
): Promise<void> {
424425
try {
425426
const clientEntry = Array.from(SWITCHBOARD.clients.entries()).find(
@@ -434,6 +435,7 @@ async function sendCostResponseToClient(
434435
type: 'message-cost-response',
435436
promptId: userInputId,
436437
credits: creditsUsed,
438+
agentId,
437439
})
438440
} else {
439441
logger.warn(
@@ -543,6 +545,7 @@ export const saveMessage = async (value: {
543545
usesUserApiKey?: boolean
544546
chargeUser?: boolean
545547
costOverrideDollars?: number
548+
agentId?: string
546549
}): Promise<number> =>
547550
withLoggerContext(
548551
{
@@ -612,6 +615,7 @@ export const saveMessage = async (value: {
612615
value.clientSessionId,
613616
value.userInputId,
614617
creditsUsed,
618+
value.agentId,
615619
)
616620

617621
const savedMessageResult = await insertMessageRecord({

backend/src/llm-apis/vercel-ai-sdk/ai-sdk.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const promptAiSdkStream = async function* (
7272
chargeUser?: boolean
7373
thinkingBudget?: number
7474
userInputId: string
75+
agentId?: string
7576
maxRetries?: number
7677
onCostCalculated?: (credits: number) => Promise<void>
7778
} & Omit<Parameters<typeof streamText>[0], 'model'>,
@@ -210,6 +211,7 @@ export const promptAiSdkStream = async function* (
210211
latencyMs: Date.now() - startTime,
211212
chargeUser: options.chargeUser ?? true,
212213
costOverrideDollars,
214+
agentId: options.agentId,
213215
})
214216

215217
// Call the cost callback if provided
@@ -229,6 +231,7 @@ export const promptAiSdk = async function (
229231
model: Model
230232
userId: string | undefined
231233
chargeUser?: boolean
234+
agentId?: string
232235
onCostCalculated?: (credits: number) => Promise<void>
233236
} & Omit<Parameters<typeof generateText>[0], 'model'>,
234237
): Promise<string> {
@@ -276,6 +279,7 @@ export const promptAiSdk = async function (
276279
finishedAt: new Date(),
277280
latencyMs: Date.now() - startTime,
278281
chargeUser: options.chargeUser ?? true,
282+
agentId: options.agentId,
279283
})
280284

281285
// Call the cost callback if provided
@@ -300,6 +304,7 @@ export const promptAiSdkStructured = async function <T>(options: {
300304
temperature?: number
301305
timeout?: number
302306
chargeUser?: boolean
307+
agentId?: string
303308
onCostCalculated?: (credits: number) => Promise<void>
304309
}): Promise<T> {
305310
if (
@@ -350,6 +355,7 @@ export const promptAiSdkStructured = async function <T>(options: {
350355
finishedAt: new Date(),
351356
latencyMs: Date.now() - startTime,
352357
chargeUser: options.chargeUser ?? true,
358+
agentId: options.agentId,
353359
})
354360

355361
// Call the cost callback if provided

backend/src/prompt-agent-stream.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const getAgentStreamFromTemplate = (params: {
1313
userInputId: string
1414
userId: string | undefined
1515
onCostCalculated?: (credits: number) => Promise<void>
16+
agentId?: string
1617

1718
template: AgentTemplate
1819
}) => {
@@ -22,6 +23,7 @@ export const getAgentStreamFromTemplate = (params: {
2223
userInputId,
2324
userId,
2425
onCostCalculated,
26+
agentId,
2527
template,
2628
} = params
2729

@@ -42,6 +44,7 @@ export const getAgentStreamFromTemplate = (params: {
4244
userId,
4345
maxOutputTokens: 32_000,
4446
onCostCalculated,
47+
agentId,
4548
}
4649

4750
// Add Gemini-specific options if needed

backend/src/run-agent-step.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { renderToolResults } from '@codebuff/common/tools/utils'
1010
import { buildArray } from '@codebuff/common/util/array'
1111
import { generateCompactId } from '@codebuff/common/util/string'
1212

13-
1413
import { asyncAgentManager } from './async-agent-manager'
1514
import { getFileReadingUpdates } from './get-file-reading-updates'
1615
import { checkLiveUserInput } from './live-user-inputs'
@@ -253,19 +252,11 @@ export const runAgentStep = async (
253252
fingerprintId,
254253
userInputId,
255254
userId,
255+
agentId: agentState.agentId,
256256
template: agentTemplate,
257257
onCostCalculated: async (credits: number) => {
258258
try {
259259
agentState.creditsUsed += credits
260-
logger.debug(
261-
{
262-
agentId: agentState.agentId,
263-
credits,
264-
totalCredits: agentState.creditsUsed,
265-
},
266-
'Added LLM cost to agent state',
267-
)
268-
269260
// Transactional cost attribution: ensure costs are actually deducted
270261
// This is already handled by the saveMessage function which calls updateUserCycleUsage
271262
// If that fails, the promise rejection will bubble up and halt agent execution
@@ -623,13 +614,5 @@ export const loopAgentSteps = async (
623614
throw error
624615
} finally {
625616
// Ensure costs are always captured, even on failure
626-
logger.debug(
627-
{
628-
agentId: currentAgentState.agentId,
629-
creditsUsed: currentAgentState.creditsUsed,
630-
status: 'completed_or_failed',
631-
},
632-
'Agent execution completed with cost tracking',
633-
)
634617
}
635618
}

common/src/actions.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,10 @@ export const MessageCostResponseSchema = z.object({
102102
type: z.literal('message-cost-response'),
103103
promptId: z.string(),
104104
credits: z.number(),
105+
agentId: z.string().optional(),
105106
})
106107
export type MessageCostResponse = z.infer<typeof MessageCostResponseSchema>
107108

108-
109-
110109
export const PromptResponseSchema = z.object({
111110
type: z.literal('prompt-response'),
112111
promptId: z.string(),

npm-app/src/cli-handlers/subagent-list.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { pluralize } from '@codebuff/common/util/string'
22
import { green, yellow, cyan, magenta, bold, gray } from 'picocolors'
33

4-
import { getSubagentsChronological } from '../subagent-storage'
4+
import {
5+
getSubagentsChronological,
6+
type SubagentData,
7+
} from '../subagent-storage'
58
import { enterSubagentBuffer } from './traces'
69
import {
710
ENTER_ALT_BUFFER,
@@ -19,14 +22,7 @@ let persistentSelectedIndex = -1 // -1 means not initialized
1922
let scrollOffset = 0
2023
let allContentLines: string[] = []
2124
let subagentLinePositions: number[] = []
22-
let subagentList: Array<{
23-
agentId: string
24-
agentType: string
25-
prompt?: string
26-
isActive: boolean
27-
lastActivity: number
28-
startTime: number
29-
}> = []
25+
let subagentList: SubagentData[] = []
3026

3127
export function isInSubagentListMode(): boolean {
3228
return isInSubagentListBuffer
@@ -255,7 +251,11 @@ function buildAllContentLines() {
255251
const timeInfo = agent.isActive
256252
? green(`[Active - ${startTime}]`)
257253
: gray(`[${startTime}]`)
258-
const headerLine = `${agentInfo} ${timeInfo}`
254+
const creditsDisplay =
255+
agent.creditsUsed > 0
256+
? yellow(` (${pluralize(agent.creditsUsed, 'credit')})`)
257+
: ''
258+
const headerLine = `${agentInfo}${creditsDisplay} ${timeInfo}`
259259

260260
const contentForBox = [headerLine, ...promptLines.map((p) => gray(p))]
261261

npm-app/src/cli-handlers/traces.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../utils/terminal'
2020

2121
import type { SubagentData } from '../subagent-storage'
22+
import { logger } from '../utils/logger'
2223

2324
// Add helpers to truncate to first line and reduce sections
2425
function firstLine(text: string): string {
@@ -52,6 +53,9 @@ export function isInSubagentBufferMode(): boolean {
5253
* Display a formatted list of traces with enhanced styling
5354
*/
5455
export function displaySubagentList(agents: SubagentData[]) {
56+
// Added: log list render
57+
logger.debug({ count: agents.length }, 'Rendering subagent list!')
58+
5559
console.log(bold(cyan('🤖 Available Traces')))
5660
console.log(gray(`Found ${pluralize(agents.length, 'trace')}`))
5761
console.log()
@@ -63,6 +67,7 @@ export function displaySubagentList(agents: SubagentData[]) {
6367
// Truncate prompt preview to first line
6468
const promptFirst = agent.prompt ? firstLine(agent.prompt) : '(no prompt)'
6569
const promptPreview = gray(promptFirst)
70+
6671
console.log(
6772
` ${status} ${bold(agent.agentId)} ${gray(`(${agent.agentType})`)}`,
6873
)
@@ -71,7 +76,6 @@ export function displaySubagentList(agents: SubagentData[]) {
7176
})
7277
}
7378
}
74-
7579
export function enterSubagentBuffer(
7680
rl: any,
7781
agentId: string,
@@ -168,6 +172,16 @@ function updateSubagentContent() {
168172
if (agentData.prompt) {
169173
const promptLine = bold(gray(`Prompt: ${firstLine(agentData.prompt)}`))
170174
wrappedLines.push(...wrapLine(promptLine, terminalWidth))
175+
}
176+
177+
// Add credits used if any
178+
if (agentData.creditsUsed > 0) {
179+
const creditsLine = yellow(`${pluralize(agentData.creditsUsed, 'credit')}`)
180+
wrappedLines.push(...wrapLine(creditsLine, terminalWidth))
181+
}
182+
183+
// Add a separator line if we added prompt or credits
184+
if (agentData.prompt || agentData.creditsUsed > 0) {
171185
wrappedLines.push('')
172186
}
173187

npm-app/src/client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
getAllSubagentIds,
8080
markSubagentInactive,
8181
storeSubagentChunk,
82+
addCreditsByAgentId,
8283
} from './subagent-storage'
8384
import { handleToolCall } from './tool-handlers'
8485
import { identifyUser, trackEvent } from './utils/analytics'
@@ -872,6 +873,11 @@ export class Client {
872873
this.creditsByPromptId[response.promptId] = []
873874
}
874875
this.creditsByPromptId[response.promptId].push(response.credits)
876+
877+
// Attribute credits directly to the agentId (backend now always provides it)
878+
if (response.agentId) {
879+
addCreditsByAgentId(response.agentId, response.credits)
880+
}
875881
})
876882

877883
this.webSocket.subscribe('usage-response', (action) => {
@@ -908,10 +914,9 @@ export class Client {
908914
this.webSocket.subscribe('request-reconnect', () => {
909915
this.reconnectWhenNextIdle()
910916
})
911-
912917
// Handle subagent streaming messages
913918
this.webSocket.subscribe('subagent-response-chunk', (action) => {
914-
const { userInputId, agentId, agentType, chunk, prompt } = action
919+
const { agentId, agentType, chunk, prompt } = action
915920

916921
// Store the chunk locally
917922
storeSubagentChunk({ agentId, agentType, chunk, prompt })

npm-app/src/subagent-storage.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as path from 'path'
44
import { endToolTag } from '@codebuff/common/tools/constants'
55

66
import { getProjectRoot } from './project-files'
7+
import { logger } from './utils/logger'
78

89
interface SubagentMessage {
910
timestamp: number
@@ -19,6 +20,8 @@ export interface SubagentData {
1920
isActive: boolean
2021
lastActivity: number
2122
startTime: number
23+
// Add: accumulated credits used by this subagent
24+
creditsUsed: number
2225
}
2326

2427
// Global storage for all subagent data
@@ -47,6 +50,11 @@ export function setTraceEnabled(enabled: boolean) {
4750
}
4851
} catch (error) {
4952
console.warn('Warning: Could not clear traces directory:', error)
53+
// Restore: existing warning log for failed directory clearing
54+
logger.warn(
55+
{ error: error instanceof Error ? error.message : String(error) },
56+
'Failed clearing traces directory',
57+
)
5058
}
5159
}
5260
}
@@ -115,6 +123,8 @@ export function storeSubagentChunk({
115123
isActive: true,
116124
lastActivity: now,
117125
startTime: now,
126+
// Initialize new field
127+
creditsUsed: 0,
118128
})
119129
}
120130

@@ -137,6 +147,16 @@ export function storeSubagentChunk({
137147
writeToTraceLog(agentId, agentType, chunk)
138148
}
139149

150+
/**
151+
* Add credits directly by agentId
152+
*/
153+
export function addCreditsByAgentId(agentId: string, credits: number) {
154+
const data = subagentStorage.get(agentId)
155+
if (data) {
156+
data.creditsUsed += credits
157+
}
158+
}
159+
140160
/**
141161
* Get all messages for a specific subagent
142162
*/

0 commit comments

Comments
 (0)