Repository navigation
♨️ feat: Prewarm Stateful Code Sandboxes with Cold-Boot UX Feedback #14239
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
Changes from all commits
ac984cc
26f5665
7300e20
4d201cb
88aa531
22d052b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas'); | |
| const { | ||
| Tools, | ||
| StepTypes, | ||
| StepEvents, | ||
| FileContext, | ||
| ErrorTypes, | ||
| UsageEvents, | ||
|
|
@@ -21,6 +22,7 @@ const { | |
| createToolExecuteHandler, | ||
| HOST_FILE_AUTHORING_ARTIFACT_KEY, | ||
| isCodeSessionToolName, | ||
| shouldSignalSandboxStart, | ||
| } = require('@librechat/api'); | ||
| const { processFileCitations } = require('~/server/services/Files/Citations'); | ||
| const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process'); | ||
|
|
@@ -228,6 +230,37 @@ async function emitEvent(res, streamId, eventData) { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Emits `on_sandbox_starting` for each code-execution tool call in the run | ||
| * step when the conversation's stateful sandbox is still cold-booting, so the | ||
| * UI can explain the first call's boot latency instead of showing a generic | ||
| * running state. Only signals while a fired prewarm remains unresolved | ||
| * ({@link shouldSignalSandboxStart}); stateless deployments never fire one | ||
| * and completed boots clear the marker, so both stay on the generic label. | ||
| * @param {ServerResponse} res - The server response object | ||
| * @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode | ||
| * @param {StreamEventData} data - The `on_run_step` event data | ||
| * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function maybeEmitSandboxStarting(res, streamId, data, metadata) { | ||
| const conversationId = metadata?.thread_id; | ||
| if (!conversationId || !(await shouldSignalSandboxStart(conversationId))) { | ||
| return; | ||
| } | ||
|
Comment on lines
+246
to
+250
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the first stateful code call happens inside a subagent, the child run-step is forwarded as an Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferring (raised in the previous round as well): subagent code steps render via the SubagentCall ticker (folded content parts), which has no consumer for this atom — forwarding the event without that renderer integration is inert. Tracked as a follow-up together with the file-authoring renderer below. |
||
| const toolCalls = data?.stepDetails?.tool_calls ?? []; | ||
| for (const toolCall of toolCalls) { | ||
| const name = toolCall?.name ?? toolCall?.function?.name; | ||
|
Comment on lines
+251
to
+253
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the code tool runs inside a subagent, its run-step is delivered through the visible Useful? React with 👍 / 👎. |
||
| if (!toolCall?.id || name == null || !isCodeSessionToolName(name)) { | ||
| continue; | ||
|
Comment on lines
+254
to
+255
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a stateful run whose first sandbox-bound action is Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid nuance, deferring for the same renderer-consumer reason as the subagent thread: file-authoring calls render via FileAuthoringCall, which doesn't consume the sandbox-starting atom, so emitting for those names alone changes nothing visible. The right follow-up extends both the signal (pass the host file-authoring tool set to isCodeSessionToolName here) and the FileAuthoringCall/SubagentCall renderers together. Scoped v1 to the execute_code/bash_tool cards deliberately. |
||
| } | ||
| await emitEvent(res, streamId, { | ||
| event: StepEvents.ON_SANDBOX_STARTING, | ||
| data: { tool_call_id: toolCall.id, runId: metadata?.run_id }, | ||
|
Comment on lines
+258
to
+259
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In resumable/Redis streams, this new UI-only event is not represented in aggregated message content, and Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged, but leaving as-is intentionally:
Comment on lines
+257
to
+259
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This emits Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same rationale as the round-1 thread on this line: the event is intentionally ephemeral live-stream status. Persisting it for replay needs TTL/invalidation to avoid replaying a stale 'starting' label after the boot completed, and the reconnect-during-boot gap degrades to the pre-PR generic label for a few seconds. Deferring until resumable streams have a transient-event channel.
Comment on lines
+258
to
+259
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In resumable/Redis streams, Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferring (third raise of this item — see the two earlier threads on this line for full rationale): the event is intentionally ephemeral live-stream status, and persisting it for replay without TTL/invalidation would replay stale 'starting' labels after boot completion. Reconnect-during-boot degrades to the pre-PR generic label. Will revisit when resumable streams have a transient-event channel.
Comment on lines
+257
to
+259
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this event goes through Useful? React with 👍 / 👎. |
||
| }); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Maps a {@link SubagentUpdateEvent} phase to the corresponding | ||
| * {@link GraphEvents} name that the SDK's `createContentAggregator` | ||
|
|
@@ -363,6 +396,7 @@ function getDefaultHandlers({ | |
| aggregateContent({ event, data }); | ||
| if (data?.stepDetails.type === StepTypes.TOOL_CALLS) { | ||
| await emitEvent(res, streamId, { event, data }); | ||
| await maybeEmitSandboxStarting(res, streamId, data, metadata); | ||
|
Comment on lines
398
to
+399
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This only adds the cold-boot event on top-level Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferring this one: subagent code steps render through the SubagentCall ticker/dialog (folded content parts), not the atom-driven ExecuteCode/BashCall cards, so emitting the event for child steps has no consumer without a separate ticker integration. The cold-boot cost is also typically already paid by the parent conversation's prewarm before a subagent spawns. Happy to do the ticker integration as a follow-up if desired. |
||
| } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { | ||
| await emitEvent(res, streamId, { event, data }); | ||
| } else if (!metadata?.hide_sequential_outputs) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -68,6 +68,7 @@ const { | |
| appendYouTubeVideoParts, | ||
| resolveYouTubeInjectionConfig, | ||
| decrementPendingRequest, | ||
| maybePrewarmCodeSandbox, | ||
| } = require('@librechat/api'); | ||
| const { | ||
| Callback, | ||
|
|
@@ -1386,6 +1387,16 @@ class AgentClient extends BaseClient { | |
| abortController = new AbortController(); | ||
| } | ||
|
|
||
| /** Fire-and-forget: boot the per-conversation stateful sandbox in | ||
| * parallel with generation so the first execute_code/bash call lands | ||
| * on a warm VM. No-op unless a reachable agent resolved | ||
| * `statefulCodeSessions`. */ | ||
| maybePrewarmCodeSandbox({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This prewarm only runs on the fresh Useful? React with 👍 / 👎. |
||
| req: this.options.req, | ||
| conversationId: this.conversationId, | ||
| agents: [this.options.agent, ...(this.agentConfigs?.values() ?? [])], | ||
| }); | ||
|
|
||
| /** @type {AppConfig['endpoints']['agents']} */ | ||
| const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents]; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import type { | |
| SummaryContentPart, | ||
| TMessageContentParts, | ||
| SubagentUpdateEvent, | ||
| SandboxStartingEvent, | ||
| } from 'librechat-data-provider'; | ||
| import type { SetterOrUpdater } from 'recoil'; | ||
| import type { AnnounceOptions } from '~/common'; | ||
|
|
@@ -26,8 +27,8 @@ import { | |
| initSubagentAggregatorState, | ||
| initSubagentTickerState, | ||
| } from '~/utils/subagentContent'; | ||
| import { subagentProgressByToolCallId, sandboxStartingByToolCallId } from '~/store'; | ||
| import { isAskUserQuestionPart } from '~/utils/approval'; | ||
| import { subagentProgressByToolCallId } from '~/store'; | ||
| import { MESSAGE_UPDATE_INTERVAL } from '~/common'; | ||
|
|
||
| type TUseStepHandler = { | ||
|
|
@@ -55,7 +56,8 @@ type TStepEvent = | |
| | { event: StepEvents.ON_SUMMARIZE_START; data: Agents.SummarizeStartEvent } | ||
| | { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent } | ||
| | { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent } | ||
| | { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent }; | ||
| | { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent } | ||
| | { event: StepEvents.ON_SANDBOX_STARTING; data: SandboxStartingEvent }; | ||
|
|
||
| type MessageDeltaUpdate = { type: ContentTypes.TEXT; text: string; tool_call_ids?: string[] }; | ||
|
|
||
|
|
@@ -284,6 +286,41 @@ export default function useStepHandler({ | |
| [], | ||
| ); | ||
|
|
||
| /** Tool-call ids whose sandbox-starting atom is set, so completion can clear them. */ | ||
| const knownSandboxAtomKeys = useRef(new Set<string>()); | ||
|
|
||
| const setSandboxStarting = useRecoilCallback( | ||
| ({ set }) => | ||
| (toolCallId: string): void => { | ||
| knownSandboxAtomKeys.current.add(toolCallId); | ||
| set(sandboxStartingByToolCallId(toolCallId), true); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a stream is cancelled, errors, or the user switches conversations before Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 26f5665 — added
Comment on lines
+293
to
+296
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a standard SSE stream is closed or the user navigates away while the sandbox-starting flag is set, this atom can survive because it is keyed only by Useful? React with 👍 / 👎. |
||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| const clearSandboxStarting = useRecoilCallback( | ||
| ({ reset }) => | ||
| (toolCallId?: string | null): void => { | ||
| if (!toolCallId || !knownSandboxAtomKeys.current.has(toolCallId)) { | ||
| return; | ||
| } | ||
| knownSandboxAtomKeys.current.delete(toolCallId); | ||
| reset(sandboxStartingByToolCallId(toolCallId)); | ||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| const resetSandboxAtoms = useRecoilCallback( | ||
| ({ reset }) => | ||
| (): void => { | ||
| for (const toolCallId of knownSandboxAtomKeys.current) { | ||
| reset(sandboxStartingByToolCallId(toolCallId)); | ||
| } | ||
| knownSandboxAtomKeys.current.clear(); | ||
| }, | ||
| [], | ||
| ); | ||
|
|
||
| /** | ||
| * Calculate content index for a run step. | ||
| * For edited content scenarios, offset by initialContent length. | ||
|
|
@@ -934,6 +971,7 @@ export default function useStepHandler({ | |
| const { result } = stepEvent.data; | ||
|
|
||
| const { id: stepId } = result; | ||
| clearSandboxStarting(result.tool_call?.id); | ||
|
|
||
| const runStep = stepMap.current.get(stepId); | ||
| let responseMessageId = runStep?.runId ?? ''; | ||
|
|
@@ -977,6 +1015,8 @@ export default function useStepHandler({ | |
| }), | ||
| ); | ||
| } | ||
| } else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) { | ||
| setSandboxStarting(stepEvent.data.tool_call_id); | ||
|
Comment on lines
+1018
to
+1019
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using only Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In parallel or subagent runs where separate model calls can reuse per-run tool IDs such as Useful? React with 👍 / 👎. |
||
| } else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) { | ||
| applySubagentUpdate(stepEvent.data); | ||
| } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) { | ||
|
|
@@ -1082,6 +1122,8 @@ export default function useStepHandler({ | |
| calculateContentIndex, | ||
| getCurrentMessages, | ||
| applySubagentUpdate, | ||
| setSandboxStarting, | ||
| clearSandboxStarting, | ||
| onSkillAuthoringComplete, | ||
| ], | ||
| ); | ||
|
|
@@ -1098,6 +1140,11 @@ export default function useStepHandler({ | |
| subagentRunToToolCallId.current.clear(); | ||
| claimedSubagentToolCallIds.current.clear(); | ||
| pendingSubagentBuffer.current.clear(); | ||
| /** Unlike subagent atoms below, sandbox-starting flags are transient | ||
| * status with no audit value — reset them at this boundary so an | ||
| * interrupted cold boot can't leak a stale "starting" label onto a | ||
| * later tool call that reuses the same id (e.g. `call_0`). */ | ||
| resetSandboxAtoms(); | ||
| /** Intentionally NOT calling `resetSubagentAtoms()` here — users need | ||
| * to be able to reopen the SubagentCall dialog after completion to | ||
| * audit what the child did. `resetSubagentAtoms` is returned below | ||
|
|
@@ -1106,7 +1153,7 @@ export default function useStepHandler({ | |
| * persisted `subagent_content` takes over for historical messages | ||
| * once the conversation is saved, and we prevent unbounded | ||
| * atomFamily growth across multi-conversation sessions. */ | ||
| }, []); | ||
| }, [resetSandboxAtoms]); | ||
|
|
||
| /** | ||
| * Sync a message into the step handler's messageMap. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { atomFamily } from 'recoil'; | ||
|
|
||
| /** | ||
| * True while the backend reported the stateful code sandbox is cold-booting | ||
| * for this tool call (`on_sandbox_starting` SSE event). Keyed by | ||
| * `tool_call_id`; `ExecuteCode`/`BashCall` swap their in-progress label to a | ||
| * "starting sandbox" message while set. Cleared when the tool call's run | ||
| * step completes. | ||
| */ | ||
| export const sandboxStartingByToolCallId = atomFamily<boolean, string>({ | ||
| key: 'sandboxStartingByToolCallId', | ||
| default: false, | ||
| }); |
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.
When Redis/Keyv is configured and the sandbox prewarm cache read rejects, this awaited
shouldSignalSandboxStartcall propagates out of theON_RUN_STEPhandler, so a transient cache issue in a nonessential UX signal can interrupt the tool-call stream after the run step is emitted. Since the sandbox-starting event is cosmetic, wrap this check in a try/catch and skip the signal on cache failure rather than letting the chat run fail.Useful? React with 👍 / 👎.