-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(chat): keep desktop tools running in chats left mid-turn #8311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
waleedlatif1
wants to merge
3
commits into
staging
Choose a base branch
from
fix/desktop-chat-switch-stops-stream
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
apps/sim/app/workspace/[workspaceId]/home/hooks/stream/client-tool-start.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { type CurrentBrowserToolName, isCurrentBrowserToolName } from '@sim/browser-protocol' | ||
| import { isTerminalToolName } from '@sim/terminal-protocol' | ||
| import { | ||
| MothershipStreamV1ToolPhase, | ||
| MothershipStreamV1ToolStatus, | ||
| } from '@/lib/mothership/generated/mothership-stream-v1' | ||
| import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' | ||
| import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' | ||
| import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' | ||
|
|
||
| export type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }> | ||
|
|
||
| interface ClientToolCall { | ||
| toolCallId: string | ||
| args: Record<string, unknown> | ||
| /** The envelope's emission timestamp; executors drop stale replays by it. */ | ||
| eventTs?: string | ||
| } | ||
|
|
||
| /** | ||
| * A tool call the orchestrator hands to this client, by the executor that runs | ||
| * it. The orchestrator blocks until the client reports the call's outcome, so | ||
| * every start must reach its executor. | ||
| */ | ||
| export type ClientToolStart = ClientToolCall & | ||
| ( | ||
| | { kind: 'workflow' | 'localFilesystem' | 'terminal'; toolName: string } | ||
| | { kind: 'browser'; toolName: CurrentBrowserToolName } | ||
| ) | ||
|
|
||
| /** | ||
| * Resolves the client-executed tool call a stream event hands this client, or | ||
| * null when the event starts nothing. Only a complete call frame that is not | ||
| * held behind an approval prompt starts a tool; whether the call is still | ||
| * pending is the caller's to decide from what it has seen. | ||
| */ | ||
| export function resolveClientToolStart(event: ToolEvent): ClientToolStart | null { | ||
| const payload = event.payload | ||
| if ( | ||
| 'previewPhase' in payload || | ||
| payload.phase === MothershipStreamV1ToolPhase.args_delta || | ||
| payload.phase === MothershipStreamV1ToolPhase.result || | ||
| payload.partial === true || | ||
| payload.status === MothershipStreamV1ToolStatus.generating || | ||
| payload.status === MothershipStreamV1ToolStatus.awaiting_approval | ||
| ) { | ||
| return null | ||
| } | ||
|
|
||
| const { toolCallId, toolName } = payload | ||
| const args = payload.arguments as Record<string, unknown> | undefined | ||
| const call = { toolCallId, args: args ?? {}, eventTs: event.ts } | ||
| if (isWorkflowToolName(toolName)) return { ...call, kind: 'workflow', toolName } | ||
| if (isNativeFileTool(toolName) || isUserLocalVfsToolCall(toolName, args)) { | ||
| return { ...call, kind: 'localFilesystem', toolName } | ||
| } | ||
| if (isCurrentBrowserToolName(toolName)) return { ...call, kind: 'browser', toolName } | ||
| if (isTerminalToolName(toolName)) return { ...call, kind: 'terminal', toolName } | ||
| return null | ||
| } |
243 changes: 243 additions & 0 deletions
243
apps/sim/app/workspace/[workspaceId]/home/hooks/stream/detached-client-tools.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| /** | ||
| * Keeps a chat's client-executed tools running after the user leaves it | ||
| * mid-turn. Leaving detaches the chat view but not the server run, and that | ||
| * run's browser, terminal, and local filesystem tools execute only in this | ||
| * client: the orchestrator blocks until the client reports each outcome, so | ||
| * without a reader the left chat stalls at its next such call. A relay reads | ||
| * the left turn's stream headlessly and starts those tools until the run ends | ||
| * or the chat's view reads the stream again. Relays live at module scope | ||
| * because switching chats remounts the chat surface that detached them. Each | ||
| * holds one resume connection, and only while its run is live. | ||
| */ | ||
| import { createLogger } from '@sim/logger' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { interruptibleSleep } from '@sim/utils/helpers' | ||
| import { backoffWithJitter } from '@sim/utils/retry' | ||
| import { readSSELines } from '@/lib/core/utils/sse' | ||
| import { desktopChatScopeId } from '@/lib/desktop/chat-scope' | ||
| import { | ||
| MothershipStreamV1EventType, | ||
| MothershipStreamV1ToolPhase, | ||
| } from '@/lib/mothership/generated/mothership-stream-v1' | ||
| import { | ||
| isTerminalStreamStatus, | ||
| type PersistedStreamEventEnvelope, | ||
| parsePersistedStreamEventEnvelopeJson, | ||
| } from '@/lib/mothership/request/session/contract' | ||
| import { executeBrowserToolOnClient } from '@/lib/mothership/tools/client/browser-tool-execution' | ||
| import { launchLocalFilesystemTool } from '@/lib/mothership/tools/client/launch-local-filesystem-tool' | ||
| import { executeTerminalToolOnClient } from '@/lib/mothership/tools/client/terminal-tool-execution' | ||
| import { | ||
| type ClientToolStart, | ||
| resolveClientToolStart, | ||
| } from '@/app/workspace/[workspaceId]/home/hooks/stream/client-tool-start' | ||
| import { | ||
| buildStreamResumeUrl, | ||
| createStreamSchemaValidationError, | ||
| getStreamEventCursor, | ||
| isAlreadyProcessedStreamCursor, | ||
| isStreamSchemaValidationError, | ||
| parseStreamBatchResponse, | ||
| resolveChatIdFromStreamBatch, | ||
| resolveChatIdFromStreamEvent, | ||
| STREAM_BATCH_FETCH_TIMEOUT_MS, | ||
| STREAM_IDLE_TIMEOUT_MS, | ||
| } from '@/app/workspace/[workspaceId]/home/hooks/stream-protocol' | ||
|
|
||
| const logger = createLogger('DetachedClientTools') | ||
|
|
||
| /** | ||
| * Responses after which there is nothing left to relay: no run exists for the | ||
| * stream (404), or this client can no longer read it (401, 403). | ||
| */ | ||
| function isRelayEndStatus(status: number): boolean { | ||
| return status === 404 || status === 401 || status === 403 | ||
| } | ||
|
|
||
| /** A live turn the user left. */ | ||
| export interface DetachedChatTurn { | ||
| streamId: string | ||
| /** The turn's chat when the view knew it; a new chat's id is read off the stream. */ | ||
| chatId?: string | ||
| /** The last cursor the chat view dispatched; the relay resumes after it. */ | ||
| afterCursor: string | ||
| traceparent?: string | ||
| workspaceId?: string | ||
| /** The owner key the chat's desktop scope derives from. */ | ||
| scopeKey: string | ||
| } | ||
|
|
||
| interface Relay { | ||
| controller: AbortController | ||
| /** Known up front or read off the stream; tools run in this chat's desktop scope. */ | ||
| chatId?: string | ||
| } | ||
|
|
||
| /** Relays by stream id. An entry is removed when its relay ends. */ | ||
| const relays = new Map<string, Relay>() | ||
|
|
||
| /** The call a tool result frame settles, or undefined for any other event. */ | ||
| function settledToolCallId(event: PersistedStreamEventEnvelope): string | undefined { | ||
| if (event.type !== MothershipStreamV1EventType.tool || 'previewPhase' in event.payload) { | ||
| return undefined | ||
| } | ||
| return event.payload.phase === MothershipStreamV1ToolPhase.result | ||
| ? event.payload.toolCallId | ||
| : undefined | ||
| } | ||
|
|
||
| /** | ||
| * Starts the client tools a detached turn hands this client. Workflow runs are | ||
| * left alone: running one drives the workflow editor, and the server runs a | ||
| * workflow call itself when no client picks it up. | ||
| */ | ||
| function startDetachedClientTool( | ||
| turn: DetachedChatTurn, | ||
| chatId: string, | ||
| start: ClientToolStart | ||
| ): void { | ||
| const { toolCallId, toolName, args, eventTs } = start | ||
| const scopeId = desktopChatScopeId(turn.scopeKey, chatId) | ||
| switch (start.kind) { | ||
| case 'workflow': | ||
| return | ||
| case 'localFilesystem': | ||
| launchLocalFilesystemTool(toolCallId, toolName, args, { | ||
| workspaceId: turn.workspaceId, | ||
| chatId, | ||
| }) | ||
| return | ||
| case 'browser': | ||
| executeBrowserToolOnClient(toolCallId, start.toolName, args, scopeId, eventTs) | ||
| return | ||
| case 'terminal': | ||
| executeTerminalToolOnClient(toolCallId, args, scopeId, eventTs) | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| return | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Relays until the run ends or the relay is aborted. Like the chat view's own | ||
| * reconnect, every connection first reads the events past the cursor as one | ||
| * batch, so calls that already have a result are settled before any call frame | ||
| * replays, then tails live. That makes any cursor a safe starting point. | ||
| */ | ||
| async function relayClientTools(turn: DetachedChatTurn, relay: Relay): Promise<void> { | ||
| const { streamId } = turn | ||
| const { signal } = relay.controller | ||
| const headers = turn.traceparent ? { traceparent: turn.traceparent } : undefined | ||
| /** Calls this relay started or saw settle; ids alone decide what is pending. */ | ||
| const handledToolCallIds = new Set<string>() | ||
| let cursor = turn.afterCursor | ||
| let failedAttempts = 0 | ||
|
|
||
| const applyEvent = (event: PersistedStreamEventEnvelope): void => { | ||
| const eventCursor = getStreamEventCursor(event) | ||
| if (isAlreadyProcessedStreamCursor(eventCursor, cursor)) return | ||
| cursor = eventCursor | ||
| relay.chatId ??= resolveChatIdFromStreamEvent(event) | ||
| if (event.type !== MothershipStreamV1EventType.tool) return | ||
| const settledId = settledToolCallId(event) | ||
| if (settledId) { | ||
| handledToolCallIds.add(settledId) | ||
| return | ||
| } | ||
| const start = resolveClientToolStart(event) | ||
| if (!start || handledToolCallIds.has(start.toolCallId)) return | ||
| handledToolCallIds.add(start.toolCallId) | ||
| if (!relay.chatId) { | ||
| logger.error('Detached client tool arrived before its chat id', { | ||
| streamId, | ||
| toolCallId: start.toolCallId, | ||
| }) | ||
| return | ||
| } | ||
| startDetachedClientTool(turn, relay.chatId, start) | ||
| } | ||
|
|
||
| /** Reads the events past the cursor at once; resolves true once there is nothing left to relay. */ | ||
| const readBatch = async (): Promise<boolean> => { | ||
| // boundary-raw-fetch: stream-resume batch endpoint needs per-request traceparent propagation the contract layer does not model | ||
| const response = await fetch(buildStreamResumeUrl(streamId, cursor, { batch: true }), { | ||
| signal: AbortSignal.any([signal, AbortSignal.timeout(STREAM_BATCH_FETCH_TIMEOUT_MS)]), | ||
| headers, | ||
| }) | ||
| if (isRelayEndStatus(response.status)) return true | ||
| if (!response.ok) throw new Error(`Stream batch responded with status ${response.status}`) | ||
| const batch = parseStreamBatchResponse(await response.json()) | ||
| relay.chatId ??= resolveChatIdFromStreamBatch(batch) | ||
| for (const { event } of batch.events) { | ||
| const settledId = settledToolCallId(event) | ||
| if (settledId) handledToolCallIds.add(settledId) | ||
| } | ||
| for (const { event } of batch.events) applyEvent(event) | ||
| return isTerminalStreamStatus(batch.status) | ||
| } | ||
|
|
||
| /** Tails one live connection; resolves true once there is nothing left to relay. */ | ||
| const readTail = async (): Promise<boolean> => { | ||
| // boundary-raw-fetch: live SSE tail endpoint streams events consumed via readSSELines | ||
| const response = await fetch(buildStreamResumeUrl(streamId, cursor), { signal, headers }) | ||
| if (isRelayEndStatus(response.status)) return true | ||
| if (!response.ok || !response.body) { | ||
| throw new Error(`Stream tail responded with status ${response.status}`) | ||
| } | ||
| let complete = false | ||
| await readSSELines(response.body, { | ||
| signal, | ||
| idleTimeoutMs: STREAM_IDLE_TIMEOUT_MS, | ||
| onData: (raw) => { | ||
| const parsed = parsePersistedStreamEventEnvelopeJson(raw) | ||
| if (!parsed.ok) throw createStreamSchemaValidationError(parsed, 'Detached SSE event.') | ||
| applyEvent(parsed.event) | ||
| if (parsed.event.type === MothershipStreamV1EventType.complete) { | ||
| complete = true | ||
| return true | ||
| } | ||
| }, | ||
| }) | ||
| return complete | ||
| } | ||
|
|
||
| while (!signal.aborted) { | ||
| const cursorBeforeAttempt = cursor | ||
| try { | ||
| if ((await readBatch()) || (await readTail())) return | ||
| } catch (error) { | ||
| if (signal.aborted) return | ||
| if (isStreamSchemaValidationError(error)) { | ||
| logger.error('Stopped relaying detached client tools on an invalid stream event', { | ||
| streamId, | ||
| error: error.message, | ||
| }) | ||
| return | ||
| } | ||
| logger.warn('Detached stream read failed', { streamId, error: getErrorMessage(error) }) | ||
| } | ||
| if (cursor !== cursorBeforeAttempt) { | ||
| failedAttempts = 0 | ||
| continue | ||
| } | ||
| failedAttempts++ | ||
| await interruptibleSleep(backoffWithJitter(failedAttempts, null), signal) | ||
| } | ||
| } | ||
|
|
||
| /** Relays a left turn's client tools until its run ends or its chat's view reads it again. */ | ||
| export function detachClientTools(turn: DetachedChatTurn): void { | ||
| relays.get(turn.streamId)?.controller.abort('superseded_detached_relay') | ||
| const relay: Relay = { controller: new AbortController(), chatId: turn.chatId } | ||
| relays.set(turn.streamId, relay) | ||
| void relayClientTools(turn, relay).finally(() => { | ||
| if (relays.get(turn.streamId) === relay) relays.delete(turn.streamId) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Stops relaying a stream the chat view reads again. Tools the relay already | ||
| * started keep running and report their outcome. | ||
| */ | ||
| export function reattachClientTools(streamId: string): void { | ||
| relays.get(streamId)?.controller.abort('chat_reattached') | ||
| relays.delete(streamId) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Organization-owned detached chats pass no
workspaceId, butlaunchLocalFilesystemToolrefuses user-local filesystem calls without one. The relay then consumes the tool event without starting it or reporting an error, leaving the server run stalled; allow user-local execution without a workspace context or explicitly settle unsupported calls.Prompt for AI agents