Skip to content

Commit 484a977

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@ebfb681dc28c9870f426e091bf3002aa6546a94f
1 parent 961d715 commit 484a977

8 files changed

Lines changed: 484 additions & 5 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* A native tool call's provider metadata (Gemini 3's thought signature) must
3+
* reach the history's tool-call part, or the next request is refused with
4+
* "Function call is missing a thought_signature" (HTTP 400).
5+
*/
6+
import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime'
7+
import { getInitialSessionState } from '@codebuff/common/types/session-state'
8+
import { describe, expect, it } from 'bun:test'
9+
10+
import { mockFileContext } from './test-utils'
11+
import { processStream } from '../tools/stream-parser'
12+
13+
import type { AgentTemplate } from '../templates/types'
14+
import type { StreamChunk } from '@codebuff/common/types/contracts/llm'
15+
import type { PromptResult } from '@codebuff/common/util/error'
16+
17+
const template: AgentTemplate = {
18+
id: 'test-agent',
19+
displayName: 'Test Agent',
20+
spawnerPrompt: 'Test agent',
21+
model: 'gemini-3.5-flash-lite',
22+
inputSchema: {},
23+
outputMode: 'structured_output',
24+
includeMessageHistory: true,
25+
inheritParentSystemPrompt: false,
26+
mcpServers: {},
27+
toolNames: ['end_turn'],
28+
spawnableAgents: [],
29+
systemPrompt: 'Test system prompt',
30+
instructionsPrompt: 'Test instructions',
31+
stepPrompt: 'Test step prompt',
32+
}
33+
34+
async function historyAfter(chunks: StreamChunk[]) {
35+
async function* stream(): AsyncGenerator<
36+
StreamChunk,
37+
PromptResult<string | null>
38+
> {
39+
for (const chunk of chunks) yield chunk
40+
return { aborted: false, value: 'msg-id' }
41+
}
42+
const agentState = getInitialSessionState(mockFileContext).mainAgentState
43+
agentState.messageHistory = []
44+
await processStream({
45+
...TEST_AGENT_RUNTIME_IMPL,
46+
sendAction: () => {},
47+
agentContext: {},
48+
agentState,
49+
agentStepId: 'test-step-id',
50+
agentTemplate: template,
51+
ancestorRunIds: [],
52+
clientSessionId: 'test-session',
53+
fileContext: mockFileContext,
54+
fingerprintId: 'test-fingerprint',
55+
fullResponse: '',
56+
localAgentTemplates: { 'test-agent': template },
57+
messages: agentState.messageHistory,
58+
prompt: 'test prompt',
59+
repoId: undefined,
60+
repoUrl: undefined,
61+
runId: 'test-run-id',
62+
signal: new AbortController().signal,
63+
stream: stream(),
64+
system: 'test system',
65+
tools: {},
66+
userId: 'test-user',
67+
userInputId: 'test-input-id',
68+
streamFinishReason: () => 'tool_calls',
69+
onCostCalculated: async () => {},
70+
onResponseChunk: () => {},
71+
})
72+
return agentState.messageHistory.flatMap((message) =>
73+
message.role === 'assistant' && Array.isArray(message.content)
74+
? message.content.filter((part) => part.type === 'tool-call')
75+
: [],
76+
)
77+
}
78+
79+
describe('processStream — tool-call provider metadata', () => {
80+
it('stores the metadata a tool call arrived with on its history part', async () => {
81+
const providerOptions = {
82+
byok: {
83+
extra_content: { google: { thought_signature: 'sig-A' } },
84+
model: 'gemini-3.5-flash-lite',
85+
},
86+
}
87+
const calls = await historyAfter([
88+
{
89+
type: 'tool-call',
90+
toolCallId: 'provider-id',
91+
toolName: 'end_turn',
92+
input: {},
93+
providerOptions,
94+
},
95+
])
96+
expect(calls).toHaveLength(1)
97+
expect(calls[0]).toMatchObject({ toolName: 'end_turn', providerOptions })
98+
})
99+
100+
it('adds no metadata to a call that had none', async () => {
101+
const calls = await historyAfter([
102+
{ type: 'tool-call', toolCallId: 'c1', toolName: 'end_turn', input: {} },
103+
])
104+
expect(calls).toHaveLength(1)
105+
expect(calls[0]).not.toHaveProperty('providerOptions')
106+
})
107+
})

‎packages/agent-runtime/src/tool-stream-parser.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55

66
import type { StreamParserState } from './util/stream-xml-parser'
77
import type { StreamChunk } from '@codebuff/common/types/contracts/llm'
8+
import type { ProviderMetadata } from '@codebuff/common/types/messages/provider-metadata'
89
import type {
910
PrintModeError,
1011
PrintModeText,
@@ -23,6 +24,7 @@ export async function* processStreamWithTools(params: {
2324
onTagEnd: (
2425
tagName: string,
2526
params: Record<string, any>,
27+
providerOptions?: ProviderMetadata,
2628
) => void | Promise<void>
2729
}
2830
>
@@ -34,6 +36,7 @@ export async function* processStreamWithTools(params: {
3436
onTagEnd: (
3537
tagName: string,
3638
params: Record<string, any>,
39+
providerOptions?: ProviderMetadata,
3740
) => void | Promise<void>
3841
}
3942
onResponseChunk: (chunk: PrintModeText | PrintModeError) => void
@@ -59,8 +62,9 @@ export async function* processStreamWithTools(params: {
5962
async function processToolCallObject(params: {
6063
toolName: string
6164
input: any
65+
providerOptions?: ProviderMetadata
6266
}): Promise<void> {
63-
const { toolName } = params
67+
const { toolName, providerOptions } = params
6468
let { input } = params
6569

6670
// AI SDK sometimes emits tool-call chunks with a raw JSON string as `input`
@@ -75,7 +79,7 @@ export async function* processStreamWithTools(params: {
7579
const processor = processors[toolName] ?? defaultProcessor(toolName)
7680

7781
await processor.onTagStart(toolName, {})
78-
await processor.onTagEnd(toolName, input)
82+
await processor.onTagEnd(toolName, input, providerOptions)
7983
}
8084

8185
function flush() {

‎packages/agent-runtime/src/tools/stream-parser.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636
Message,
3737
ToolMessage,
3838
} from '@codebuff/common/types/messages/codebuff-message'
39+
import type { ProviderMetadata } from '@codebuff/common/types/messages/provider-metadata'
3940
import type { PrintModeEvent } from '@codebuff/common/types/print-mode'
4041
import type { Subgoal } from '@codebuff/common/types/session-state'
4142
import type { ProjectFileContext } from '@codebuff/common/util/file'
@@ -243,6 +244,10 @@ export async function processStream(
243244
const toolResultsToAddToMessageHistory: ToolMessage[] = []
244245
const toolCalls: (CodebuffToolCall | CustomToolCall)[] = []
245246
const toolCallsToAddToMessageHistory: (CodebuffToolCall | CustomToolCall)[] = []
247+
// The provider metadata each native tool call arrived with (e.g. Gemini's
248+
// thought signature), stored on its history part so the next request can
249+
// replay it. Kept off the tool call itself, which also reaches clients.
250+
const toolCallProviderOptions = new Map<string, ProviderMetadata>()
246251
const assistantMessages: Message[] = []
247252
// Inline agents replace the parent's history with their result. Track which
248253
// current-step messages they inherited so finalization does not append them
@@ -296,7 +301,11 @@ export async function processStream(
296301
const responseHandler = createResponseHandler()
297302
return {
298303
onTagStart: () => { },
299-
onTagEnd: async (_: string, input: Record<string, string>) => {
304+
onTagEnd: async (
305+
_: string,
306+
input: Record<string, string>,
307+
providerOptions?: ProviderMetadata,
308+
) => {
300309
sawToolCall = true
301310
if (signal.aborted || followupSuggestionLimitHit) {
302311
return
@@ -325,6 +334,9 @@ export async function processStream(
325334
}
326335
}
327336
const toolCallId = generateCompactId()
337+
if (providerOptions) {
338+
toolCallProviderOptions.set(toolCallId, providerOptions)
339+
}
328340
const isNativeTool = toolNames.includes(toolName as ToolName)
329341

330342
// Check if this is an agent tool call that should be transformed to spawn_agents
@@ -743,7 +755,14 @@ export async function processStream(
743755
...assistantMessages.filter(
744756
(message) => !claimedByInlineAgent.has(message),
745757
),
746-
...filteredToolCalls.map((toolCall) => assistantMessage({ ...toolCall, type: 'tool-call' })),
758+
...filteredToolCalls.map((toolCall) => {
759+
const providerOptions = toolCallProviderOptions.get(toolCall.toolCallId)
760+
return assistantMessage({
761+
...toolCall,
762+
type: 'tool-call',
763+
...(providerOptions ? { providerOptions } : {}),
764+
})
765+
}),
747766
...toolResultsToAddToMessageHistory,
748767
...errorMessages,
749768
])

‎packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,31 @@ function getOpenAIMetadata(message: {
1414
return message?.providerOptions?.openaiCompatible ?? {}
1515
}
1616

17+
/**
18+
* The `extra_content` a tool call arrived with (Gemini's thought signature),
19+
* when it was captured under this provider's name for this same model.
20+
* Signatures are only valid for the model that produced them.
21+
*/
22+
function replayableExtraContent(
23+
part: { providerOptions?: SharedV2ProviderMetadata },
24+
options: { providerOptionsName?: string; modelId?: string } | undefined,
25+
): JSONValue | undefined {
26+
if (!options?.providerOptionsName) return undefined
27+
const namespace = part.providerOptions?.[options.providerOptionsName]
28+
const extraContent = namespace?.extra_content
29+
if (
30+
extraContent == null ||
31+
typeof extraContent !== 'object' ||
32+
Array.isArray(extraContent)
33+
) {
34+
return undefined
35+
}
36+
if (options.modelId !== undefined && namespace?.model !== options.modelId) {
37+
return undefined
38+
}
39+
return extraContent
40+
}
41+
1742
/** Approximate payload size of a file part's data, for placeholder text. */
1843
function filePartByteLength(data: unknown): number {
1944
let value = data
@@ -137,6 +162,7 @@ export function convertToOpenAICompatibleChatMessages(
137162
id: string
138163
type: 'function'
139164
function: { name: string; arguments: string }
165+
extra_content?: JSONValue
140166
}> = []
141167

142168
for (const part of content) {
@@ -173,13 +199,17 @@ export function convertToOpenAICompatibleChatMessages(
173199
break
174200
}
175201
case 'tool-call': {
202+
const extraContent = replayableExtraContent(part, options)
176203
toolCalls.push({
177204
id: part.toolCallId,
178205
type: 'function',
179206
function: {
180207
name: part.toolName,
181208
arguments: JSON.stringify(part.input),
182209
},
210+
...(extraContent !== undefined
211+
? { extra_content: extraContent }
212+
: {}),
183213
...partMetadata,
184214
})
185215
break

‎packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
288288
toolCallId: toolCall.id ?? generateId(),
289289
toolName: toolCall.function.name,
290290
input: toolCall.function.arguments!,
291+
...toolCallExtraContentMetadata(
292+
toolCall.extra_content,
293+
this.providerOptionsName,
294+
this.modelId,
295+
),
291296
})
292297
}
293298
}
@@ -376,6 +381,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
376381
name: string
377382
arguments: string
378383
}
384+
extraContent?: unknown
379385
hasFinished: boolean
380386
}> = []
381387

@@ -606,6 +612,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
606612
name: toolCallDelta.function.name,
607613
arguments: toolCallDelta.function.arguments ?? '',
608614
},
615+
extraContent: toolCallDelta.extra_content ?? undefined,
609616
hasFinished: false,
610617
}
611618

@@ -637,6 +644,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
637644
toolCallId: toolCall.id ?? generateId(),
638645
toolName: toolCall.function.name,
639646
input: toolCall.function.arguments,
647+
...toolCallExtraContentMetadata(
648+
toolCall.extraContent,
649+
providerOptionsName,
650+
modelId,
651+
),
640652
})
641653
toolCall.hasFinished = true
642654
}
@@ -652,6 +664,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
652664
continue
653665
}
654666

667+
if (toolCallDelta.extra_content != null) {
668+
toolCall.extraContent = toolCallDelta.extra_content
669+
}
670+
655671
if (toolCallDelta.function?.arguments != null) {
656672
toolCall.function!.arguments +=
657673
toolCallDelta.function?.arguments ?? ''
@@ -680,6 +696,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
680696
toolCallId: toolCall.id ?? generateId(),
681697
toolName: toolCall.function.name,
682698
input: toolCall.function.arguments,
699+
...toolCallExtraContentMetadata(
700+
toolCall.extraContent,
701+
providerOptionsName,
702+
modelId,
703+
),
683704
})
684705
toolCall.hasFinished = true
685706
}
@@ -730,6 +751,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
730751
toolCallId: toolCall.id ?? generateId(),
731752
toolName: toolCall.function.name,
732753
input: toolCall.function.arguments,
754+
...toolCallExtraContentMetadata(
755+
toolCall.extraContent,
756+
providerOptionsName,
757+
modelId,
758+
),
733759
})
734760
}
735761

@@ -773,6 +799,32 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
773799
}
774800
}
775801

802+
/**
803+
* A tool call's `extra_content`, kept so the next request can send it back.
804+
* Gemini 3's OpenAI-compatible endpoint puts the call's thought signature at
805+
* `extra_content.google.thought_signature` and refuses (HTTP 400) a follow-up
806+
* whose replayed call lacks it. Like `reasoning_details`, it is tagged with
807+
* the model that produced it and only replayed to that model.
808+
*/
809+
function toolCallExtraContentMetadata(
810+
extraContent: unknown,
811+
providerOptionsName: string,
812+
modelId: string,
813+
): { providerMetadata?: SharedV2ProviderMetadata } {
814+
if (
815+
extraContent == null ||
816+
typeof extraContent !== 'object' ||
817+
Array.isArray(extraContent)
818+
) {
819+
return {}
820+
}
821+
return {
822+
providerMetadata: {
823+
[providerOptionsName]: { extra_content: extraContent, model: modelId },
824+
} as SharedV2ProviderMetadata,
825+
}
826+
}
827+
776828
/**
777829
* The slot of a streamed tool-call delta that carries no `index`. OpenAI
778830
* always sends one, but several OpenAI-compatible servers and proxies do not
@@ -848,6 +900,7 @@ const OpenAICompatibleChatResponseSchema = z.object({
848900
name: z.string(),
849901
arguments: z.string(),
850902
}),
903+
extra_content: z.unknown().nullish(),
851904
}),
852905
)
853906
.nullish(),
@@ -908,6 +961,7 @@ const createOpenAICompatibleChatChunkSchema = <
908961
name: z.string().nullish(),
909962
arguments: z.string().nullish(),
910963
}),
964+
extra_content: z.unknown().nullish(),
911965
}),
912966
)
913967
.nullish(),

0 commit comments

Comments
 (0)