Skip to content

Commit 386811e

Browse files
committed
Implement web search and docs search in the agent runtime, using our web endpoint. Track costs
1 parent aa9feb3 commit 386811e

File tree

8 files changed

+529
-484
lines changed

8 files changed

+529
-484
lines changed

packages/agent-runtime/src/__tests__/read-docs-tool.test.ts

Lines changed: 111 additions & 184 deletions
Large diffs are not rendered by default.

packages/agent-runtime/src/__tests__/web-search-tool.test.ts

Lines changed: 93 additions & 168 deletions
Large diffs are not rendered by default.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { withTimeout } from '@codebuff/common/util/promise'
2+
import { env } from '@codebuff/common/env'
3+
4+
import type { Logger } from '@codebuff/common/types/contracts/logger'
5+
6+
const FETCH_TIMEOUT_MS = 30_000
7+
8+
export async function callWebSearchAPI(params: {
9+
query: string
10+
depth?: 'standard' | 'deep'
11+
repoUrl?: string | null
12+
fetch: typeof globalThis.fetch
13+
logger: Logger
14+
baseUrl?: string
15+
apiKey?: string
16+
}): Promise<{ result?: string; error?: string; creditsUsed?: number }> {
17+
const { query, depth = 'standard', repoUrl, fetch, logger } = params
18+
const baseUrl = params.baseUrl ?? env.NEXT_PUBLIC_CODEBUFF_APP_URL
19+
const apiKey = params.apiKey ?? process.env.CODEBUFF_API_KEY
20+
21+
if (!baseUrl || !apiKey) {
22+
return { error: 'Missing Codebuff base URL or API key' }
23+
}
24+
25+
const url = `${baseUrl}/api/v1/web-search`
26+
const payload = { query, depth, ...(repoUrl ? { repoUrl } : {}) }
27+
28+
try {
29+
const res = await withTimeout(
30+
fetch(url, {
31+
method: 'POST',
32+
headers: {
33+
'Content-Type': 'application/json',
34+
Authorization: `Bearer ${apiKey}`,
35+
'x-codebuff-api-key': apiKey,
36+
},
37+
body: JSON.stringify(payload),
38+
}),
39+
FETCH_TIMEOUT_MS,
40+
)
41+
42+
const text = await res.text()
43+
const tryJson = () => {
44+
try {
45+
return JSON.parse(text)
46+
} catch {
47+
return null
48+
}
49+
}
50+
51+
if (!res.ok) {
52+
const maybe = tryJson()
53+
const err =
54+
(maybe && (maybe.error || maybe.message)) || text || 'Request failed'
55+
logger.warn(
56+
{
57+
url,
58+
status: res.status,
59+
statusText: res.statusText,
60+
body: text?.slice(0, 500),
61+
},
62+
'Web API web-search request failed',
63+
)
64+
return { error: typeof err === 'string' ? err : 'Unknown error' }
65+
}
66+
67+
const data = tryJson()
68+
if (data && typeof data.result === 'string') {
69+
return {
70+
result: data.result,
71+
creditsUsed: typeof data.creditsUsed === 'number' ? data.creditsUsed : undefined,
72+
}
73+
}
74+
if (data && typeof data.error === 'string') return { error: data.error }
75+
return { error: 'Invalid response format' }
76+
} catch (error) {
77+
logger.error(
78+
{
79+
error:
80+
error instanceof Error
81+
? { name: error.name, message: error.message, stack: error.stack }
82+
: error,
83+
},
84+
'Web API web-search network error',
85+
)
86+
return { error: error instanceof Error ? error.message : 'Network error' }
87+
}
88+
}
89+
90+
export async function callDocsSearchAPI(params: {
91+
libraryTitle: string
92+
topic?: string
93+
maxTokens?: number
94+
repoUrl?: string | null
95+
fetch: typeof globalThis.fetch
96+
logger: Logger
97+
baseUrl?: string
98+
apiKey?: string
99+
}): Promise<{ documentation?: string; error?: string; creditsUsed?: number }> {
100+
const { libraryTitle, topic, maxTokens, repoUrl, fetch, logger } = params
101+
const baseUrl = params.baseUrl ?? env.NEXT_PUBLIC_CODEBUFF_APP_URL
102+
const apiKey = params.apiKey ?? process.env.CODEBUFF_API_KEY
103+
104+
if (!baseUrl || !apiKey) {
105+
return { error: 'Missing Codebuff base URL or API key' }
106+
}
107+
108+
const url = `${baseUrl}/api/v1/docs-search`
109+
const payload: Record<string, any> = { libraryTitle }
110+
if (topic) payload.topic = topic
111+
if (typeof maxTokens === 'number') payload.maxTokens = maxTokens
112+
if (repoUrl) payload.repoUrl = repoUrl
113+
114+
try {
115+
const res = await withTimeout(
116+
fetch(url, {
117+
method: 'POST',
118+
headers: {
119+
'Content-Type': 'application/json',
120+
Authorization: `Bearer ${apiKey}`,
121+
'x-codebuff-api-key': apiKey,
122+
},
123+
body: JSON.stringify(payload),
124+
}),
125+
FETCH_TIMEOUT_MS,
126+
)
127+
128+
const text = await res.text()
129+
const tryJson = () => {
130+
try {
131+
return JSON.parse(text) as any
132+
} catch {
133+
return null
134+
}
135+
}
136+
137+
if (!res.ok) {
138+
const maybe = tryJson()
139+
const err =
140+
(maybe && (maybe.error || maybe.message)) || text || 'Request failed'
141+
logger.warn(
142+
{
143+
url,
144+
status: res.status,
145+
statusText: res.statusText,
146+
body: text?.slice(0, 500),
147+
},
148+
'Web API docs-search request failed',
149+
)
150+
return { error: typeof err === 'string' ? err : 'Unknown error' }
151+
}
152+
153+
const data = tryJson()
154+
if (data && typeof data.documentation === 'string') {
155+
return {
156+
documentation: data.documentation,
157+
creditsUsed: typeof data.creditsUsed === 'number' ? data.creditsUsed : undefined,
158+
}
159+
}
160+
if (data && typeof data.error === 'string') return { error: data.error }
161+
return { error: 'Invalid response format' }
162+
} catch (error) {
163+
logger.error(
164+
{
165+
error:
166+
error instanceof Error
167+
? { name: error.name, message: error.message, stack: error.stack }
168+
: error,
169+
},
170+
'Web API docs-search network error',
171+
)
172+
return { error: error instanceof Error ? error.message : 'Network error' }
173+
}
174+
}

packages/agent-runtime/src/run-agent-step.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const runAgentStep = async (
8181
| 'agentTemplate'
8282
| 'agentContext'
8383
| 'fullResponse'
84+
| 'onCostCalculated'
8485
> &
8586
ParamsExcluding<
8687
typeof getAgentStreamFromTemplate,
@@ -245,27 +246,19 @@ export const runAgentStep = async (
245246

246247
const { model } = agentTemplate
247248

249+
let stepCreditsUsed = 0
250+
251+
const onCostCalculated = async (credits: number) => {
252+
stepCreditsUsed += credits
253+
agentState.creditsUsed += credits
254+
agentState.directCreditsUsed += credits
255+
}
256+
248257
const { getStream } = getAgentStreamFromTemplate({
249258
...params,
250259
agentId: agentState.parentId ? agentState.agentId : undefined,
251260
template: agentTemplate,
252-
onCostCalculated: async (credits: number) => {
253-
try {
254-
agentState.creditsUsed += credits
255-
agentState.directCreditsUsed += credits
256-
// Transactional cost attribution: ensure costs are actually deducted
257-
// This is already handled by the saveMessage function which calls updateUserCycleUsage
258-
// If that fails, the promise rejection will bubble up and halt agent execution
259-
} catch (error) {
260-
logger.error(
261-
{ agentId: agentState.agentId, credits, error },
262-
'Failed to add cost to agent state',
263-
)
264-
throw new Error(
265-
`Cost tracking failed for agent ${agentState.agentId}: ${error}`,
266-
)
267-
}
268-
},
261+
onCostCalculated,
269262
includeCacheControl: supportsCacheControl(agentTemplate.model),
270263
})
271264

@@ -315,6 +308,7 @@ export const runAgentStep = async (
315308
agentTemplate,
316309
agentContext,
317310
fullResponse,
311+
onCostCalculated,
318312
})
319313
toolResults.push(...newToolResults)
320314

@@ -423,6 +417,7 @@ export const runAgentStep = async (
423417
toolResults,
424418
agentContext: newAgentContext,
425419
fullResponseChunks,
420+
stepCreditsUsed,
426421
},
427422
`End agent ${agentType} step ${iterationNum} (${userInputId}${prompt ? ` - Prompt: ${prompt.slice(0, 20)}` : ''})`,
428423
)
@@ -465,6 +460,7 @@ export async function loopAgentSteps(
465460
| 'stepsComplete'
466461
| 'stepNumber'
467462
| 'system'
463+
| 'onCostCalculated'
468464
> &
469465
ParamsExcluding<typeof getAgentTemplate, 'agentId'> &
470466
ParamsExcluding<
@@ -676,6 +672,10 @@ export async function loopAgentSteps(
676672
system,
677673
stepsComplete: shouldEndTurn,
678674
stepNumber: totalSteps,
675+
onCostCalculated: async (credits: number) => {
676+
agentState.creditsUsed += credits
677+
agentState.directCreditsUsed += credits
678+
},
679679
})
680680
const {
681681
agentState: programmaticAgentState,

0 commit comments

Comments
 (0)