Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Per-model support is generated from the model registry on the [Agent block page]
|--------|----------|------------|
| Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces). The newest Claude generations omit full thinking; Sim requests summarized thinking for them on streaming runs | Yes |
| Gemini / Vertex | Yes when a thinking level is set (thought summaries requested on agent-events runs) | Yes |
| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop today (answer streams; chips may be absent) |
| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop; tool chips arrive settled (start + end together) once tools finish, ahead of the streamed answer — no live in-progress chips yet |
| OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) |
| Bedrock | Not invented | Yes when streaming tool loop is used |

Expand Down
25 changes: 21 additions & 4 deletions apps/sim/providers/anthropic/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,11 +852,17 @@ export async function executeAnthropicProviderRequest(

const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_use is never executed on this path. `tools` must stay
* (history contains tool_use blocks) but tool choice is pinned to none;
* with tools present and no choice, `auto` would let the model re-call.
*/
const streamingPayload = {
...payload,
messages: currentMessages,
stream: true,
tool_choice: undefined,
tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined,
}

const streamResponse = await anthropic.messages.create(
Expand Down Expand Up @@ -890,7 +896,12 @@ export async function executeAnthropicProviderRequest(
createReadableStreamFromAnthropicStream(
streamResponse as AsyncIterable<RawMessageStreamEvent>,
({ content: streamContent, usage, thinking }) => {
output.content = streamContent
if (!streamContent && content) {
logger.warn(
`${providerLabel} final stream produced no text; keeping tool-loop answer`
)
}
output.content = streamContent || content
output.tokens = {
input: tokens.input + usage.input_tokens,
output: tokens.output + usage.output_tokens,
Expand Down Expand Up @@ -1286,11 +1297,12 @@ export async function executeAnthropicProviderRequest(
if (request.stream) {
logger.info(`Using streaming for final ${providerLabel} response after tool processing`)

/** Same regeneration guard as the primary path: prose only, no re-calls. */
const streamingPayload = {
...payload,
messages: currentMessages,
stream: true,
tool_choice: undefined,
tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined,
}

const streamResponse = await anthropic.messages.create(
Expand Down Expand Up @@ -1324,7 +1336,12 @@ export async function executeAnthropicProviderRequest(
createReadableStreamFromAnthropicStream(
streamResponse as AsyncIterable<RawMessageStreamEvent>,
({ content: streamContent, usage, thinking }) => {
output.content = streamContent
if (!streamContent && content) {
logger.warn(
`${providerLabel} final stream produced no text; keeping tool-loop answer`
)
}
output.content = streamContent || content
output.tokens = {
input: tokens.input + usage.input_tokens,
output: tokens.output + usage.output_tokens,
Expand Down
13 changes: 10 additions & 3 deletions apps/sim/providers/azure-openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,10 +477,14 @@ async function executeChatCompletionsRequest(

const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_calls are never executed on this path.
*/
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
Expand Down Expand Up @@ -520,8 +524,11 @@ async function executeChatCompletionsRequest(
: undefined,
streamFormat: 'agent-events-v1',
createStream: ({ output, finalizeTiming }) =>
createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => {
output.content = content
createReadableStreamFromAzureOpenAIStream(streamResponse, (streamedContent, usage) => {
if (!streamedContent && content) {
logger.warn('Azure OpenAI final stream produced no text; keeping tool-loop answer')
}
output.content = streamedContent || content
output.tokens = {
input: tokens.input + usage.prompt_tokens,
output: tokens.output + usage.completion_tokens,
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/providers/bedrock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,16 @@ export const bedrockProvider: ProviderConfig = {
streamFormat: 'agent-events-v1',
createStream: ({ output, finalizeTiming }) =>
createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => {
output.content = streamContent
/**
* Bedrock's ToolChoice has no `none`, and toolConfig is required
* when history carries toolUse blocks — the regeneration can
* re-call a tool that is never executed on this path. Keep the
* tool loop's settled answer when the stream ends without text.
*/
if (!streamContent && content) {
logger.warn('Bedrock final stream produced no text; keeping tool-loop answer')
}
output.content = streamContent || content
output.tokens = {
input: tokens.input + usage.inputTokens,
output: tokens.output + usage.outputTokens,
Expand Down
13 changes: 10 additions & 3 deletions apps/sim/providers/cerebras/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,14 @@ export const cerebrasProvider: ProviderConfig = {
if (request.stream) {
logger.info('Using streaming for final Cerebras response after tool processing')

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_calls are never executed on this path.
*/
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
tool_choice: 'none',
stream: true,
}

Expand Down Expand Up @@ -495,8 +499,11 @@ export const cerebrasProvider: ProviderConfig = {
isStreaming: true,
streamFormat: 'agent-events-v1',
createStream: ({ output }) =>
createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => {
output.content = content
createReadableStreamFromCerebrasStream(streamResponse, (streamedContent, usage) => {
if (!streamedContent && content) {
logger.warn('Cerebras final stream produced no text; keeping tool-loop answer')
}
output.content = streamedContent || content
output.tokens = {
input: tokens.input + usage.prompt_tokens,
output: tokens.output + usage.completion_tokens,
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/providers/deepseek/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,10 +544,15 @@ export const deepseekProvider: ProviderConfig = {
if (request.stream) {
logger.info('Using streaming for final DeepSeek response after tool processing')

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_calls are never executed on this path; with `auto` a
* model can re-call and end the stream with no text.
*/
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
tool_choice: 'none',
stream: true,
}

Expand Down Expand Up @@ -594,8 +599,11 @@ export const deepseekProvider: ProviderConfig = {
createReadableStreamFromDeepseekStream(
// double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects
streamResponse as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
(content, usage, thinking) => {
output.content = content
(streamedContent, usage, thinking) => {
if (!streamedContent && content) {
logger.warn('DeepSeek final stream produced no text; keeping tool-loop answer')
}
output.content = streamedContent || content
output.tokens = {
input: tokens.input + usage.prompt_tokens,
output: tokens.output + usage.completion_tokens,
Expand Down
18 changes: 17 additions & 1 deletion apps/sim/providers/gemini/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,8 +1239,21 @@ export async function executeGeminiRequest(
request.responseFormat.schema
) as Schema
}
} else if (nextConfig.tools) {
/**
* The regeneration exists purely to stream the settled answer as
* prose — streamed function calls are never executed. With AUTO the
* model can re-decide to call a tool here, ending the stream with a
* dead functionCall and an empty answer.
*/
nextConfig.toolConfig = {
functionCallingConfig: { mode: FunctionCallingConfigMode.NONE },
}
}

/** Settled answer from the check response — kept if the stream ends without text. */
const checkAnswer = extractTextContent(checkResponse.candidates?.[0])

// Capture accumulated cost before streaming
const accumulatedCost = {
input: state.cost.input,
Expand All @@ -1267,7 +1280,10 @@ export async function executeGeminiRequest(
const stream = createReadableStreamFromGeminiStream(
streamGenerator,
(streamContent: string, usage: GeminiUsage, thinking?: string) => {
streamingResult.execution.output.content = streamContent
if (!streamContent && checkAnswer) {
logger.warn('Gemini final stream produced no text; keeping tool-loop answer')
}
streamingResult.execution.output.content = streamContent || checkAnswer
streamingResult.execution.output.tokens = {
input: accumulatedTokens.input + usage.promptTokenCount,
output: accumulatedTokens.output + usage.candidatesTokenCount,
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/providers/groq/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,10 +499,15 @@ export const groqProvider: ProviderConfig = {
if (request.stream) {
logger.info('Using streaming for final Groq response after tool processing')

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_calls are never executed on this path (re-applying the
* original forced tool_choice here would even guarantee a dead call).
*/
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: originalToolChoice || 'auto',
tool_choice: 'none' as const,
stream: true,
}

Expand Down Expand Up @@ -549,8 +554,11 @@ export const groqProvider: ProviderConfig = {
createReadableStreamFromGroqStream(
// double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes
streamResponse as unknown as AsyncIterable<ChatCompletionChunk>,
(content, usage, thinking) => {
output.content = content
(streamedContent, usage, thinking) => {
if (!streamedContent && content) {
logger.warn('Groq final stream produced no text; keeping tool-loop answer')
}
output.content = streamedContent || content
output.tokens = {
input: tokens.input + usage.prompt_tokens,
output: tokens.output + usage.completion_tokens,
Expand Down
13 changes: 10 additions & 3 deletions apps/sim/providers/mistral/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,10 +441,14 @@ export const mistralProvider: ProviderConfig = {

const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)

/**
* The regeneration exists purely to stream the settled answer as prose —
* streamed tool_calls are never executed on this path.
*/
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
tool_choice: 'none',
stream: true,
}
const streamResponse = await mistral.chat.completions.create(
Expand Down Expand Up @@ -483,8 +487,11 @@ export const mistralProvider: ProviderConfig = {
: undefined,
streamFormat: 'agent-events-v1',
createStream: ({ output }) =>
createReadableStreamFromMistralStream(streamResponse, (content, usage) => {
output.content = content
createReadableStreamFromMistralStream(streamResponse, (streamedContent, usage) => {
if (!streamedContent && content) {
logger.warn('Mistral final stream produced no text; keeping tool-loop answer')
}
output.content = streamedContent || content
output.tokens = {
input: tokens.input + usage.prompt_tokens,
output: tokens.output + usage.completion_tokens,
Expand Down
75 changes: 74 additions & 1 deletion apps/sim/providers/openai/core.reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* without explicit effort keep a reasoning-free payload, and the
* unverified-organization 400 falls back to a summary-free retry.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { executeResponsesProviderRequest } from '@/providers/openai/core'
import type { ProviderRequest } from '@/providers/types'
import { executeTool } from '@/tools'

vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))

Expand Down Expand Up @@ -162,4 +163,76 @@ describe('executeResponsesProviderRequest reasoning payload', () => {
const body = JSON.parse(fetchMock.mock.calls[0][1].body as string)
expect(body.reasoning).toBeUndefined()
})

describe('final regenerated stream after the tool loop', () => {
const TOOL_CALL_RESPONSE = {
id: 'resp_tool',
status: 'completed',
output: [{ type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}

const SETTLED_ANSWER_RESPONSE = {
id: 'resp_answer',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Settled answer from loop' }],
},
],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}

/** An SSE stream that ends without any output_text (dead function_call turn). */
function emptySseResponse() {
return new Response('data: {"type":"response.completed"}\n\ndata: [DONE]\n\n', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}

async function collect(stream: ReadableStream<unknown>) {
const events: unknown[] = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

it('forces tool_choice none and keeps the tool-loop answer when the stream has no text', async () => {
;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } })
fetchMock
.mockResolvedValueOnce(jsonResponse(TOOL_CALL_RESPONSE))
.mockResolvedValueOnce(jsonResponse(SETTLED_ANSWER_RESPONSE))
.mockResolvedValueOnce(emptySseResponse())

const result = (await run({
model: 'gpt-5.5',
stream: true,
tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any,
})) as { stream: ReadableStream<unknown>; execution: { output: { content: string } } }

expect(fetchMock).toHaveBeenCalledTimes(3)
const streamBody = JSON.parse(fetchMock.mock.calls[2][1].body as string)
expect(streamBody.stream).toBe(true)
// The regeneration exists to stream prose; streamed calls are never executed.
expect(streamBody.tool_choice).toBe('none')

const events = await collect(result.stream)
// Settled chips for the silent loop's executed calls ride ahead of the answer.
expect(events[0]).toEqual({ type: 'tool_call_start', id: 'call_1', name: 'exa_search' })
expect(events[1]).toEqual({
type: 'tool_call_end',
id: 'call_1',
name: 'exa_search',
status: 'success',
})
expect(result.execution.output.content).toBe('Settled answer from loop')
})
})
})
Loading
Loading