Skip to content
Open
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
90 changes: 90 additions & 0 deletions apps/docs/content/docs/en/workflows/deployment/agent-events.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Agent stream events
description: Protocol for streaming thinking and tool lifecycle from Agent blocks
---

import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

Agent blocks can emit more than answer text while they run: **provider-exposed thinking** (or reasoning summaries) and a **tool-call lifecycle** (name + status). Sim delivers those as typed events on an opt-in stream protocol.

<Callout type="info">
Sim does **not** invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired).
</Callout>

## Dual gate (public chat / simple SSE)

Thinking and tool frames leave the public chat or workflow simple-SSE path only when **both** are true:

1. Deployment policy: chat `includeThinking` is enabled (default **off**).
2. Request opts in with header:

```http
X-Sim-Stream-Protocol: agent-events-v1
```

Legacy clients that omit the header keep today’s text-only SSE even if the deployment has thinking enabled. The hosted chat UI always sends the header for its own deployments.

## Simple SSE frame shapes

Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older clients that append every `chunk` into the answer cannot leak thinking).

| Frame | Meaning |
|-------|---------|
| `{ "blockId", "chunk": "…" }` | Final-turn answer text only |
| `{ "blockId", "event": "thinking", "data": "…" }` | Thinking / reasoning summary delta |
| `{ "blockId", "event": "tool", "phase": "start"\|"end", "id", "name", "status?" }` | Tool lifecycle (no args / results) |
| `{ "event": "final", … }` | Terminal success envelope |
| `{ "event": "stream_error", … }` | Terminal failure (may omit `final`) |
| `data: [DONE]` | Stream closed (documented completion marker) |

### Intermediate-turn text

During a live tool loop, the model may emit preamble text before calling a tool. That text is tagged **intermediate** internally and is **not** projected into the user-facing answer channel. Only the **final** turn’s text appears as `chunk`.

### Abort

Client disconnect or Stop aborts the provider stream. In-flight tools settle as `cancelled`. Cancel is distinct from execution timeout.

### Reconnect

Canvas execution-events `stream:chunk`, `stream:thinking`, and `stream:tool` are **live-only** (not buffered for reconnect replay), same as answer chunks. Guaranteed `seq` replay is out of scope.

## Canvas (draft Run)

When you click **Run** in the builder, the execution-events SSE path forwards the same sink:

- `stream:thinking` — `{ blockId, data }`
- `stream:tool` — `{ blockId, phase, id, name, status? }`
- `stream:chunk` — answer text (unchanged)

The terminal output panel shows Thinking / Tools chrome above the block output when those events arrive. PII redaction on block output still disables live forwarding (executor rule).

## Chat deployment toggle

In **Deploy → Chat**, enable **Include thinking** so public chat can expose thinking/tool frames (still requires the protocol header). Redeploy / update the chat after changing Agent models or tools.

## Capability honesty (high level)

| Family | Thinking | Live tools |
|--------|----------|------------|
| Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces) | Yes |
| Gemini / Vertex | Yes when `includeThoughts` requested | Yes |
| OpenAI Responses | Reasoning **summaries** when streamed | Silent tool loop today (answer streams; chips may be absent) |
| 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 |

## Example (public chat)

<Tabs items={['cURL']}>
<Tab value="cURL">
```bash
curl -N -X POST 'http://localhost:3000/api/chat/your-slug' \
-H 'Content-Type: application/json' \
-H 'X-Sim-Stream-Protocol: agent-events-v1' \
-d '{"input":"Think briefly, then say hi"}'
```
</Tab>
</Tabs>

See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the Include thinking setting.
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/workflows/deployment/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Configure the following fields, then click **Launch Chat**:
| **Output** | Output fields from your workflow blocks returned as the chat response. At least one must be selected. |
| **Welcome Message** | Greeting shown before the user sends their first message. Defaults to `"Hi there! How can I help you today?"`. |
| **Access Control** | Controls who can access the chat. See [Access Control](#access-control) below. |
| **Include thinking** | When enabled, the hosted chat can show provider-exposed thinking and tool lifecycle (name + status). Requires the chat client to send `X-Sim-Stream-Protocol: agent-events-v1` (the hosted UI always does). Default is off. See [Agent stream events](/docs/workflows/deployment/agent-events). |

### Output Selection

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/workflows/deployment/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Deployment",
"pages": ["index", "api", "chat", "mcp"]
"pages": ["index", "api", "chat", "agent-events", "mcp"]
}
72 changes: 43 additions & 29 deletions apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } fro
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { noop } from '@/lib/core/utils/request'
import {
AGENT_STREAM_PROTOCOL_HEADER,
AGENT_STREAM_PROTOCOL_V1,
} from '@/lib/workflows/streaming/agent-stream-protocol'
import {
ChatErrorState,
ChatHeader,
Expand Down Expand Up @@ -95,8 +99,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [conversationId] = useState(() => generateId())

const [showScrollButton, setShowScrollButton] = useState(false)
const [userHasScrolled, setUserHasScrolled] = useState(false)
const isUserScrollingRef = useRef(false)
/** ChatGPT-style: follow new tokens only while the viewport is near the bottom. */
const stickToBottomRef = useRef(true)
const ignoreScrollRef = useRef(false)

const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)

Expand Down Expand Up @@ -131,10 +136,31 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const audioContextRef = useRef<AudioContext | null>(null)
const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef)

const scrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
const NEAR_BOTTOM_THRESHOLD_PX = 100

/**
* ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away.
* With `force` (jump button), re-pins to bottom.
*/
const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => {
const behavior = options?.behavior ?? 'smooth'
const force = options?.force === true
if (!force && !stickToBottomRef.current) return
if (!messagesEndRef.current) return

if (force) {
stickToBottomRef.current = true
setShowScrollButton(false)
}

ignoreScrollRef.current = true
messagesEndRef.current.scrollIntoView({ behavior })
window.setTimeout(
() => {
ignoreScrollRef.current = false
},
behavior === 'smooth' ? 400 : 50
)
}, [])

const scrollToMessage = useCallback(
Expand Down Expand Up @@ -165,39 +191,23 @@ export default function ChatClient({ identifier }: { identifier: string }) {
[messagesContainerRef]
)

const isStreamingResponseRef = useRef(isStreamingResponse)
isStreamingResponseRef.current = isStreamingResponse

useEffect(() => {
const container = messagesContainerRef.current
if (!container) return

const handleScroll = () => {
if (ignoreScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollButton(distanceFromBottom > 100)

if (isStreamingResponseRef.current && !isUserScrollingRef.current) {
setUserHasScrolled(true)
}
const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX
stickToBottomRef.current = nearBottom
setShowScrollButton(!nearBottom)
}

container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [chatConfig, isVoiceFirstMode, authRequired])

useEffect(() => {
if (isStreamingResponse) {
setUserHasScrolled(false)

isUserScrollingRef.current = true
const timeoutId = setTimeout(() => {
isUserScrollingRef.current = false
}, 1000)
return () => clearTimeout(timeoutId)
}
}, [isStreamingResponse])

const handleSendMessage = async (
messageParam?: string,
isVoiceInput = false,
Expand All @@ -220,7 +230,8 @@ export default function ChatClient({ identifier }: { identifier: string }) {
filesCount: files?.length,
})

setUserHasScrolled(false)
stickToBottomRef.current = true
setShowScrollButton(false)

const userMessage: ChatMessage = {
id: generateId(),
Expand All @@ -244,7 +255,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
scrollToMessage(userMessage.id, true)
}, 100)

// One AbortController for fetch + SSE body reads so Stop cancels server work too.
const abortController = new AbortController()
abortControllerRef.current = abortController
const timeoutId = setTimeout(() => {
abortController.abort()
}, CHAT_REQUEST_TIMEOUT_MS)
Expand Down Expand Up @@ -282,6 +295,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
[AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1,
},
body: JSON.stringify(payload),
credentials: 'same-origin',
Expand Down Expand Up @@ -316,8 +330,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
response,
setMessages,
setIsLoading,
scrollToBottom,
userHasScrolled,
() => scrollToBottom({ behavior: 'auto' }),
{
voiceSettings: {
isVoiceEnabled: shouldPlayAudio,
Expand All @@ -326,6 +339,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
},
audioStreamHandler: audioHandler,
outputConfigs: chatConfig?.outputConfigs,
abortController,
}
)
} catch (error) {
Expand Down Expand Up @@ -437,7 +451,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
showScrollButton={showScrollButton}
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
scrollToBottom={scrollToBottom}
scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })}
scrollToMessage={scrollToMessage}
chatConfig={chatConfig}
/>
Expand Down
Loading
Loading