Skip to content

Commit f9df0e8

Browse files
committed
fix(chat): keep desktop tools running in chats left mid-turn
1 parent 4e17dce commit f9df0e8

7 files changed

Lines changed: 447 additions & 145 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@/lib/mothership/generated/mothership-stream-v1'
1212
import {
1313
type ParseStreamEventEnvelopeFailure,
14+
type PersistedStreamEventEnvelope,
1415
parsePersistedStreamEventEnvelope,
1516
} from '@/lib/mothership/request/session/contract'
1617
import {
@@ -19,6 +20,9 @@ import {
1920
} from '@/lib/mothership/request/session/file-preview-session-contract'
2021
import type { StreamBatchEvent } from '@/lib/mothership/request/session/types'
2122

23+
/** Both live transports heartbeat every 15s; three missed heartbeats trigger cursor recovery. */
24+
export const STREAM_IDLE_TIMEOUT_MS = 45_000
25+
2226
export type StreamBatchResponse = {
2327
success: boolean
2428
events: StreamBatchEvent[]
@@ -148,6 +152,24 @@ export function isZeroStreamCursor(cursor: string): boolean {
148152
return Number.isFinite(sequence) && sequence <= 0
149153
}
150154

155+
/**
156+
* The resume endpoint for a stream's events after `afterCursor`: replayed then
157+
* tailed live, or returned as one JSON batch.
158+
*/
159+
export function buildStreamResumeUrl(
160+
streamId: string,
161+
afterCursor: string,
162+
options?: { batch?: boolean }
163+
): string {
164+
const url = `/api/mothership/chat/stream?streamId=${encodeURIComponent(streamId)}&after=${encodeURIComponent(afterCursor)}`
165+
return options?.batch ? `${url}&batch=true` : url
166+
}
167+
168+
/** The cursor an event advances its stream to; dedupes replayed events. */
169+
export function getStreamEventCursor(event: PersistedStreamEventEnvelope): string {
170+
return event.stream?.cursor ?? String(event.seq)
171+
}
172+
151173
/**
152174
* The resume endpoint 404s when no run exists for the stream — there is
153175
* nothing left to resume, so reconnect falls back to the persisted DB
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type CurrentBrowserToolName, isCurrentBrowserToolName } from '@sim/browser-protocol'
2+
import { isTerminalToolName } from '@sim/terminal-protocol'
3+
import {
4+
MothershipStreamV1ToolPhase,
5+
MothershipStreamV1ToolStatus,
6+
} from '@/lib/mothership/generated/mothership-stream-v1'
7+
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
8+
import { isNativeFileTool, isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem'
9+
import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools'
10+
11+
export type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }>
12+
13+
interface ClientToolCall {
14+
toolCallId: string
15+
args: Record<string, unknown>
16+
/** The envelope's emission timestamp; executors drop stale replays by it. */
17+
eventTs?: string
18+
}
19+
20+
/**
21+
* A tool call the orchestrator hands to this client, by the executor that runs
22+
* it. The orchestrator blocks until the client reports the call's outcome, so
23+
* every start must reach its executor.
24+
*/
25+
export type ClientToolStart = ClientToolCall &
26+
(
27+
| { kind: 'workflow' | 'localFilesystem' | 'terminal'; toolName: string }
28+
| { kind: 'browser'; toolName: CurrentBrowserToolName }
29+
)
30+
31+
/**
32+
* Resolves the client-executed tool call a stream event asks this client to
33+
* start, or null when the event starts nothing. Only a complete call frame
34+
* that is not held behind an approval prompt starts a tool.
35+
*
36+
* @param isPending - whether the call is still running without a result, as
37+
* far as the caller has seen
38+
*/
39+
export function resolveClientToolStart(
40+
event: ToolEvent,
41+
isPending: (toolCallId: string) => boolean
42+
): ClientToolStart | null {
43+
const payload = event.payload
44+
if (
45+
'previewPhase' in payload ||
46+
payload.phase === MothershipStreamV1ToolPhase.args_delta ||
47+
payload.phase === MothershipStreamV1ToolPhase.result ||
48+
payload.partial === true ||
49+
payload.status === MothershipStreamV1ToolStatus.generating ||
50+
payload.status === MothershipStreamV1ToolStatus.awaiting_approval
51+
) {
52+
return null
53+
}
54+
55+
const { toolCallId, toolName } = payload
56+
const args = payload.arguments as Record<string, unknown> | undefined
57+
const call = { toolCallId, args: args ?? {}, eventTs: event.ts }
58+
let start: ClientToolStart | null = null
59+
if (isWorkflowToolName(toolName)) {
60+
start = { ...call, kind: 'workflow', toolName }
61+
} else if (isNativeFileTool(toolName) || isUserLocalVfsToolCall(toolName, args)) {
62+
start = { ...call, kind: 'localFilesystem', toolName }
63+
} else if (isCurrentBrowserToolName(toolName)) {
64+
start = { ...call, kind: 'browser', toolName }
65+
} else if (isTerminalToolName(toolName)) {
66+
start = { ...call, kind: 'terminal', toolName }
67+
}
68+
return start && isPending(toolCallId) ? start : null
69+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Keeps a chat's client-executed tools running after the user leaves it
3+
* mid-turn. Leaving detaches the chat view but not the server run, and that
4+
* run's browser, terminal, and local filesystem tools execute only in this
5+
* client: the orchestrator blocks until the client reports each outcome, so
6+
* without a reader the left chat stalls at its next such call. A relay tails
7+
* the left chat's stream headlessly and starts those tools until the run
8+
* completes or the chat is reopened. Relays live at module scope because
9+
* switching chats remounts the chat surface that detached them. Each holds one
10+
* resume connection, so a relay exists only while its left run is live.
11+
*/
12+
import { createLogger } from '@sim/logger'
13+
import { getErrorMessage } from '@sim/utils/errors'
14+
import { interruptibleSleep } from '@sim/utils/helpers'
15+
import { backoffWithJitter } from '@sim/utils/retry'
16+
import { readSSELines } from '@/lib/core/utils/sse'
17+
import {
18+
MothershipStreamV1EventType,
19+
MothershipStreamV1ToolPhase,
20+
} from '@/lib/mothership/generated/mothership-stream-v1'
21+
import { parsePersistedStreamEventEnvelopeJson } from '@/lib/mothership/request/session/contract'
22+
import { executeBrowserToolOnClient } from '@/lib/mothership/tools/client/browser-tool-execution'
23+
import { launchLocalFilesystemTool } from '@/lib/mothership/tools/client/launch-local-filesystem-tool'
24+
import { executeTerminalToolOnClient } from '@/lib/mothership/tools/client/terminal-tool-execution'
25+
import {
26+
type ClientToolStart,
27+
resolveClientToolStart,
28+
} from '@/app/workspace/[workspaceId]/home/hooks/stream/client-tool-start'
29+
import {
30+
buildStreamResumeUrl,
31+
createStreamSchemaValidationError,
32+
getStreamEventCursor,
33+
isAlreadyProcessedStreamCursor,
34+
isStreamSchemaValidationError,
35+
STREAM_IDLE_TIMEOUT_MS,
36+
} from '@/app/workspace/[workspaceId]/home/hooks/stream-protocol'
37+
38+
const logger = createLogger('DetachedClientTools')
39+
40+
/** Consecutive tail attempts that make no progress before a relay gives up. */
41+
const MAX_STALLED_TAIL_ATTEMPTS = 5
42+
43+
/** A live turn the user left, and where its client tools run. */
44+
export interface DetachedChatTurn {
45+
chatId: string
46+
streamId: string
47+
/** The last cursor the chat view dispatched; the relay resumes after it. */
48+
afterCursor: string
49+
traceparent?: string
50+
workspaceId?: string
51+
/** The chat's desktop scope, which owns its browser and terminal tabs. */
52+
scopeId: string
53+
}
54+
55+
/** Relays by chat id. An entry is removed when its relay ends. */
56+
const relays = new Map<string, AbortController>()
57+
58+
/**
59+
* Starts the client tools a detached turn hands this client. Workflow runs are
60+
* left alone: running one drives the workflow editor, and the server runs a
61+
* workflow call itself when no client picks it up.
62+
*/
63+
function startDetachedClientTool(turn: DetachedChatTurn, start: ClientToolStart): void {
64+
const { toolCallId, toolName, args, eventTs } = start
65+
switch (start.kind) {
66+
case 'workflow':
67+
return
68+
case 'localFilesystem':
69+
launchLocalFilesystemTool(toolCallId, toolName, args, {
70+
workspaceId: turn.workspaceId,
71+
chatId: turn.chatId,
72+
})
73+
return
74+
case 'browser':
75+
executeBrowserToolOnClient(toolCallId, start.toolName, args, turn.scopeId, eventTs)
76+
return
77+
case 'terminal':
78+
executeTerminalToolOnClient(toolCallId, args, turn.scopeId, eventTs)
79+
return
80+
}
81+
}
82+
83+
async function relayClientTools(turn: DetachedChatTurn, signal: AbortSignal): Promise<void> {
84+
const { streamId, traceparent } = turn
85+
/** Calls this relay started or saw settle; ids alone decide what is pending. */
86+
const handledToolCallIds = new Set<string>()
87+
let cursor = turn.afterCursor
88+
let stalledAttempts = 0
89+
90+
/** Reads one tail connection; resolves true once the run is over. */
91+
const readTail = async (): Promise<boolean> => {
92+
// boundary-raw-fetch: live SSE tail endpoint streams events consumed via readSSELines
93+
const response = await fetch(buildStreamResumeUrl(streamId, cursor), {
94+
signal,
95+
...(traceparent ? { headers: { traceparent } } : {}),
96+
})
97+
if (response.status === 404) return true
98+
if (!response.ok || !response.body) {
99+
throw new Error(`Stream tail responded with status ${response.status}`)
100+
}
101+
102+
let complete = false
103+
await readSSELines(response.body, {
104+
signal,
105+
idleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
106+
onData: (raw) => {
107+
const parsed = parsePersistedStreamEventEnvelopeJson(raw)
108+
if (!parsed.ok) throw createStreamSchemaValidationError(parsed, 'Detached SSE event.')
109+
const event = parsed.event
110+
const eventCursor = getStreamEventCursor(event)
111+
if (isAlreadyProcessedStreamCursor(eventCursor, cursor)) return
112+
cursor = eventCursor
113+
114+
if (event.type === MothershipStreamV1EventType.tool) {
115+
const start = resolveClientToolStart(event, (id) => !handledToolCallIds.has(id))
116+
if (start) {
117+
handledToolCallIds.add(start.toolCallId)
118+
startDetachedClientTool(turn, start)
119+
} else if (
120+
!('previewPhase' in event.payload) &&
121+
event.payload.phase === MothershipStreamV1ToolPhase.result
122+
) {
123+
handledToolCallIds.add(event.payload.toolCallId)
124+
}
125+
}
126+
if (event.type === MothershipStreamV1EventType.complete) {
127+
complete = true
128+
return true
129+
}
130+
},
131+
})
132+
return complete
133+
}
134+
135+
while (!signal.aborted) {
136+
const cursorBeforeAttempt = cursor
137+
try {
138+
if (await readTail()) return
139+
} catch (error) {
140+
if (signal.aborted) return
141+
if (isStreamSchemaValidationError(error)) {
142+
logger.error('Stopped relaying detached client tools on an invalid stream event', {
143+
streamId,
144+
error: error.message,
145+
})
146+
return
147+
}
148+
logger.warn('Detached stream tail failed', { streamId, error: getErrorMessage(error) })
149+
}
150+
151+
if (cursor !== cursorBeforeAttempt) {
152+
stalledAttempts = 0
153+
continue
154+
}
155+
stalledAttempts++
156+
if (stalledAttempts >= MAX_STALLED_TAIL_ATTEMPTS) {
157+
logger.warn('Stopped relaying detached client tools after repeated stalls', {
158+
streamId,
159+
cursor,
160+
})
161+
return
162+
}
163+
await interruptibleSleep(backoffWithJitter(stalledAttempts, null), signal)
164+
}
165+
}
166+
167+
/**
168+
* Relays a left turn's client tools until its run completes or the chat is
169+
* reopened. Replaces any relay the chat already has.
170+
*/
171+
export function detachClientTools(turn: DetachedChatTurn): void {
172+
relays.get(turn.chatId)?.abort('superseded_detached_relay')
173+
const controller = new AbortController()
174+
relays.set(turn.chatId, controller)
175+
void relayClientTools(turn, controller.signal).finally(() => {
176+
if (relays.get(turn.chatId) === controller) relays.delete(turn.chatId)
177+
})
178+
}
179+
180+
/**
181+
* Stops relaying a chat the user reopened; its chat view takes the turn back.
182+
* Tools the relay already started keep running and report their outcome.
183+
*/
184+
export function reattachClientTools(chatId: string): void {
185+
relays.get(chatId)?.abort('chat_reattached')
186+
relays.delete(chatId)
187+
}

0 commit comments

Comments
 (0)