Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

664 lines
23 KiB
TypeScript

/**
* Live Gemini streaming tool loop.
*
* Each model turn uses generateContentStream. Thought parts → thinking_delta
* live; functionCall parts → tool_call_start (with local ids when the model
* omits them); text parts → `pending` text deltas live, classified by a
* `turn_end` event as intermediate vs final. Tool ends emit in completion
* order; abort → cancelled.
*
* Function-call parts are echoed back into request history verbatim — Google
* requires signatures/ids to round-trip exactly as received, so local ids are
* used only for agent events and trace segments, never injected into history.
*/
import {
type Content,
FunctionCallingConfigMode,
type GenerateContentConfig,
type GenerateContentResponse,
type GoogleGenAI,
type Part,
type Schema,
type ToolConfig,
} from '@google/genai'
import type { Logger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import type { IterationToolCall } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import {
checkForForcedToolUsage,
cleanSchemaForGemini,
convertUsageMetadata,
ensureStructResponse,
} from '@/providers/google/utils'
import { executeProviderTool } from '@/providers/runtime-context'
import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events'
import {
isAbortError,
type StreamingToolLoopComplete,
settleOpenTools,
terminateToolLoop,
} from '@/providers/streaming-tool-loop-shared'
import { ensureToolCallId } from '@/providers/tool-call-id'
import { enrichLastModelSegment } from '@/providers/trace-enrichment'
import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types'
import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils'
import type { GeminiUsage } from './types'
import { priceGeminiTokens, splitGeminiUsage } from './usage'
type GeminiStreamingToolLoopComplete = Omit<StreamingToolLoopComplete, 'tokens'> & {
tokens: { input: number; output: number; cacheRead: number; total: number }
}
export interface CreateGeminiStreamingToolLoopStreamOptions {
ai: GoogleGenAI
model: string
baseConfig: GenerateContentConfig
contents: Content[]
request: ProviderRequest
logger: Logger
timeSegments: TimeSegment[]
forcedTools?: string[]
toolConfig?: ToolConfig
onComplete: (result: GeminiStreamingToolLoopComplete) => void
}
/**
* A streamed functionCall part paired with the execution-local id used on the
* agent-events stream. The part itself stays verbatim for history echo.
*/
interface StreamedFunctionCall {
part: Part
localId: string
}
function buildNextConfig(
baseConfig: GenerateContentConfig,
currentToolConfig: ToolConfig | undefined,
usedForcedTools: string[],
forcedTools: string[],
request: ProviderRequest,
logger: Logger,
model: string
): GenerateContentConfig {
const nextConfig = { ...baseConfig }
const allForcedToolsUsed = forcedTools.length > 0 && usedForcedTools.length === forcedTools.length
if (allForcedToolsUsed && request.responseFormat) {
nextConfig.tools = undefined
nextConfig.toolConfig = undefined
if (isGemini3Model(model)) {
logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set')
} else {
nextConfig.responseMimeType = 'application/json'
nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema
logger.info('Using structured output for final response after tool execution')
}
} else if (currentToolConfig) {
nextConfig.toolConfig = currentToolConfig
} else {
nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } }
}
return nextConfig
}
/**
* Drain one generateContentStream turn into live agent events + aggregated parts.
*/
async function drainGeminiTurn(
stream: AsyncGenerator<GenerateContentResponse>,
controller: ReadableStreamDefaultController<AgentStreamEvent>,
openTools: Map<string, string>,
onIteratorChange: (
iterator: AsyncIterator<GenerateContentResponse> | undefined
) => void = () => {}
): Promise<{
text: string
thinking: string
functionCalls: StreamedFunctionCall[]
hasFunctionCallPart: boolean
usage: GeminiUsage
finishReason?: string
}> {
let text = ''
let thinking = ''
const functionCalls: StreamedFunctionCall[] = []
let hasFunctionCallPart = false
const seenKeys = new Set<string>()
let usage: GeminiUsage = {
promptTokenCount: 0,
candidatesTokenCount: 0,
cachedContentTokenCount: 0,
totalTokenCount: 0,
}
let finishReason: string | undefined
const iterator = stream[Symbol.asyncIterator]()
onIteratorChange(iterator)
try {
while (true) {
const next = await iterator.next()
if (next.done) break
const chunk = next.value
if (chunk.promptFeedback?.blockReason) {
throw new Error(
`Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${
chunk.promptFeedback.blockReasonMessage
? ` (${chunk.promptFeedback.blockReasonMessage})`
: ''
}`
)
}
if (chunk.usageMetadata) {
usage = convertUsageMetadata(chunk.usageMetadata)
}
const candidate = chunk.candidates?.[0]
if (candidate?.finishReason) {
finishReason = String(candidate.finishReason)
}
const parts = candidate?.content?.parts
if (!Array.isArray(parts)) {
const fallback = chunk.text
if (fallback) {
text += fallback
controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' })
}
continue
}
for (const part of parts) {
if (part.functionCall) {
hasFunctionCallPart = true
const localId = ensureToolCallId(part.functionCall.id, 'gemini')
const name = part.functionCall.name ?? ''
if (!seenKeys.has(localId) && name) {
seenKeys.add(localId)
functionCalls.push({ part, localId })
if (!openTools.has(localId)) {
openTools.set(localId, name)
controller.enqueue({ type: 'tool_call_start', id: localId, name })
}
}
continue
}
if (!part.text) continue
if (part.thought === true) {
thinking += part.text
controller.enqueue({ type: 'thinking_delta', text: part.text })
} else {
text += part.text
// Live pending text: sinks render it now; the pump projects it to the
// answer only when this turn's turn_end says 'final'.
controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' })
}
}
}
} finally {
onIteratorChange(undefined)
}
return { text, thinking, functionCalls, hasFunctionCallPart, usage, finishReason }
}
/**
* Multi-turn Gemini tool loop as an agent-events-v1 object stream.
*/
export function createGeminiStreamingToolLoopStream(
options: CreateGeminiStreamingToolLoopStreamOptions
): ReadableStream<AgentStreamEvent> {
const {
ai,
model,
baseConfig,
contents: initialContents,
request,
logger,
timeSegments,
onComplete,
} = options
const forcedTools = options.forcedTools ?? []
const loopAbortController = new AbortController()
const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason)
let activeStreamIterator: AsyncIterator<GenerateContentResponse> | undefined
let consumerCancelled = false
if (request.abortSignal?.aborted) {
abortFromRequest()
} else {
request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true })
}
return new ReadableStream<AgentStreamEvent>({
start(controller) {
void (async () => {
let contents = [...initialContents]
let currentToolConfig = options.toolConfig
let usedForcedTools: string[] = []
let content = ''
let iterationCount = 0
let modelCalls = 0
let sawFinalTurn = false
let modelTime = 0
let toolsTime = 0
let firstResponseTime = 0
const tokens = { input: 0, output: 0, cacheRead: 0, total: 0 }
let costInput = 0
let costOutput = 0
let costTotal = 0
let latestPricing: ModelPricing | undefined
const toolCalls: unknown[] = []
const toolResults: Record<string, unknown>[] = []
const openToolStarts = new Map<string, string>()
const reportProgress = () => {
const toolCost = sumToolCosts(toolResults)
onComplete({
content,
tokens,
cost: {
input: costInput,
output: costOutput,
toolCost: toolCost || undefined,
total: costTotal + toolCost,
pricing: latestPricing,
},
toolCalls:
toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined,
modelTime,
toolsTime,
firstResponseTime,
iterations: modelCalls,
})
}
try {
while (modelCalls <= MAX_TOOL_ITERATIONS) {
if (loopAbortController.signal.aborted) {
if (!consumerCancelled) {
settleOpenTools(controller, openToolStarts, 'cancelled')
}
throw new DOMException('Stream aborted', 'AbortError')
}
const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS
const turnConfig = finalSynthesis
? { ...baseConfig, tools: undefined, toolConfig: undefined }
: buildNextConfig(
baseConfig,
currentToolConfig,
usedForcedTools,
forcedTools,
request,
logger,
model
)
const modelStart = Date.now()
const streamGenerator = await ai.models.generateContentStream({
model,
contents,
config: {
...turnConfig,
abortSignal: loopAbortController.signal,
},
})
const drained = await drainGeminiTurn(
streamGenerator,
controller,
openToolStarts,
(iterator) => {
activeStreamIterator = iterator
}
)
const modelEnd = Date.now()
const thisModelTime = modelEnd - modelStart
modelTime += thisModelTime
modelCalls++
if (iterationCount === 0) {
firstResponseTime = thisModelTime
}
timeSegments.push({
type: 'model',
name: model,
startTime: modelStart,
endTime: modelEnd,
duration: thisModelTime,
})
const split = splitGeminiUsage(drained.usage)
tokens.input += split.input
tokens.output += split.output
tokens.cacheRead += split.cacheRead
tokens.total += drained.usage.totalTokenCount
const turnCost = priceGeminiTokens(model, split)
costInput += turnCost.input
costOutput += turnCost.output
costTotal += turnCost.total
latestPricing = turnCost.pricing
const isCompleteTurn =
drained.finishReason === 'STOP' ||
(drained.finishReason === 'MAX_TOKENS' &&
Boolean(drained.text) &&
!drained.hasFunctionCallPart)
if (!isCompleteTurn) {
settleOpenTools(controller, openToolStarts, 'error')
throw new Error(
`Gemini stream ended with finish reason ${drained.finishReason ?? 'missing'}`
)
}
if (finalSynthesis && drained.functionCalls.length > 0) {
settleOpenTools(controller, openToolStarts, 'error')
throw new Error('Gemini returned function calls during final synthesis')
}
const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final'
controller.enqueue({ type: 'turn_end', turn: turnTag })
content = drained.text
const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls
.filter((fc) => Boolean(fc.part.functionCall))
.map((fc) => ({
id: fc.localId,
name: fc.part.functionCall?.name ?? '',
arguments: (fc.part.functionCall?.args ?? {}) as Record<string, unknown>,
}))
enrichLastModelSegment(timeSegments, {
assistantContent: drained.text || undefined,
thinkingContent: drained.thinking || undefined,
toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined,
finishReason: drained.finishReason,
tokens: {
input: drained.usage.promptTokenCount,
output: drained.usage.candidatesTokenCount,
total: drained.usage.totalTokenCount,
...(split.cacheRead > 0 && { cacheRead: split.cacheRead }),
},
cost: {
input: turnCost.input,
output: turnCost.output,
total: turnCost.total,
},
provider: 'google',
})
const forcedCheck = checkForForcedToolUsage(
drained.functionCalls
.map((fc) => fc.part.functionCall)
.filter((fc): fc is NonNullable<typeof fc> => Boolean(fc)),
currentToolConfig,
forcedTools,
usedForcedTools
)
if (forcedCheck) {
usedForcedTools = forcedCheck.usedForcedTools
currentToolConfig = forcedCheck.nextToolConfig
}
if (drained.functionCalls.length === 0) {
sawFinalTurn = true
break
}
const toolsStartTime = Date.now()
const orderedResults = await Promise.all(
drained.functionCalls.map(async ({ part, localId }) => {
const functionCall = part.functionCall!
const toolCallId = localId
const toolName = functionCall.name ?? ''
const toolArgs = isRecordLike(functionCall.args) ? functionCall.args : undefined
const toolCallStartTime = Date.now()
try {
if (loopAbortController.signal.aborted) {
throw new DOMException('Stream aborted', 'AbortError')
}
if (!toolArgs) {
throw new Error(`Arguments for tool "${toolName}" must be an object`)
}
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) {
const value = {
part,
toolCallId,
toolName,
toolArgs,
toolParams: {} as Record<string, unknown>,
resultContent: {
error: true,
message: `Tool ${toolName} not found`,
tool: toolName,
},
result: undefined as
| { success: boolean; output?: unknown; error?: string }
| undefined,
startTime: toolCallStartTime,
endTime: Date.now(),
duration: Date.now() - toolCallStartTime,
status: 'error' as ToolCallEndStatus,
success: false,
}
openToolStarts.delete(toolCallId)
controller.enqueue({
type: 'tool_call_end',
id: toolCallId,
name: toolName,
status: 'error',
})
return value
}
const { toolParams, executionParams } = prepareToolExecution(
tool,
toolArgs,
request
)
const { rawResponse, modelResponse } = await executeProviderTool(
toolName,
executionParams,
{
signal: loopAbortController.signal,
}
)
const toolCallEndTime = Date.now()
const resultContent: Record<string, unknown> = rawResponse.success
? ensureStructResponse(rawResponse.output)
: {
error: true,
message: rawResponse.error || 'Tool execution failed',
tool: toolName,
}
const modelResultContent: Record<string, unknown> = modelResponse.success
? ensureStructResponse(modelResponse.output)
: {
error: true,
message: modelResponse.error || 'Tool execution failed',
tool: toolName,
}
const status: ToolCallEndStatus = rawResponse.success ? 'success' : 'error'
openToolStarts.delete(toolCallId)
controller.enqueue({
type: 'tool_call_end',
id: toolCallId,
name: toolName,
status,
})
return {
part,
toolCallId,
toolName,
toolArgs,
toolParams,
resultContent,
modelResultContent,
result: rawResponse,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
status,
success: rawResponse.success,
}
} catch (error) {
const toolCallEndTime = Date.now()
if (loopAbortController.signal.aborted) {
openToolStarts.delete(toolCallId)
if (!consumerCancelled) {
controller.enqueue({
type: 'tool_call_end',
id: toolCallId,
name: toolName,
status: 'cancelled',
})
}
throw error
}
if (isAbortError(error)) {
openToolStarts.delete(toolCallId)
controller.enqueue({
type: 'tool_call_end',
id: toolCallId,
name: toolName,
status: 'error',
})
throw error
}
logger.error('Error processing function call:', {
error: toError(error).message,
functionName: toolName,
})
const status: ToolCallEndStatus = 'error'
openToolStarts.delete(toolCallId)
controller.enqueue({
type: 'tool_call_end',
id: toolCallId,
name: toolName,
status,
})
return {
part,
toolCallId,
toolName,
toolArgs: toolArgs ?? {},
toolParams: {} as Record<string, unknown>,
resultContent: {
error: true,
message: getErrorMessage(error, 'Tool execution failed'),
tool: toolName,
},
result: undefined,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
status,
success: false,
}
}
})
)
toolsTime += Date.now() - toolsStartTime
/**
* Echo the model's functionCall parts verbatim (signatures and any
* model-provided ids must round-trip untouched). A functionResponse
* id is attached only when the model itself provided one.
*/
const modelParts: Part[] = orderedResults.map((r) => r.part)
const userParts: Part[] = orderedResults.map((r) => ({
functionResponse: {
name: r.toolName,
response: 'modelResultContent' in r ? r.modelResultContent : r.resultContent,
...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}),
},
}))
contents = [
...contents,
{ role: 'model', parts: modelParts },
{ role: 'user', parts: userParts },
]
for (const r of orderedResults) {
toolCalls.push({
name: r.toolName,
arguments: r.toolParams,
startTime: new Date(r.startTime).toISOString(),
endTime: new Date(r.endTime).toISOString(),
duration: r.duration,
result: r.resultContent,
success: r.success,
})
if (
r.success &&
r.result?.output &&
typeof r.result.output === 'object' &&
!Array.isArray(r.result.output)
) {
toolResults.push(r.result.output as Record<string, unknown>)
}
timeSegments.push({
type: 'tool',
name: r.toolName,
startTime: r.startTime,
endTime: r.endTime,
duration: r.duration,
toolCallId: r.toolCallId,
})
}
iterationCount += 1
}
if (!sawFinalTurn) {
throw new Error('Gemini tool loop ended without a final response')
}
reportProgress()
controller.close()
} catch (error) {
reportProgress()
terminateToolLoop({
controller,
openTools: openToolStarts,
aborted: loopAbortController.signal.aborted,
consumerCancelled,
error,
onUnexpectedError: (cause) =>
logger.error('Gemini streaming tool loop failed', {
error: toError(cause).message,
}),
})
} finally {
activeStreamIterator = undefined
request.abortSignal?.removeEventListener('abort', abortFromRequest)
}
})().catch((error) => {
// `start` cannot be async (the loop must not block the first pull), so
// a throw escaping the IIFE would surface as an unhandled rejection.
logger.error('Unhandled failure in Gemini streaming tool loop', {
error: toError(error).message,
})
})
},
async cancel(reason) {
consumerCancelled = true
loopAbortController.abort(reason)
await activeStreamIterator?.return?.()
request.abortSignal?.removeEventListener('abort', abortFromRequest)
},
})
}