deer-flow/frontend/src/core/api/api-client.ts
Nefelibata c6f6a01f56
fix(runtime): prevent IndexError in MemoryStreamBridge._make_gap on empty events buffer (#5047)
* fix(runtime): prevent IndexError in MemoryStreamBridge._make_gap on empty events buffer

* fix(runtime): handle empty stream replay gap bounds across backend and frontend

- Clamp MemoryStreamBridge queue_maxsize at 1 and validate StreamBridgeConfig.queue_maxsize >= 1
- Update StreamGap docstring to clarify None retained bounds
- Allow StreamReplayGapData and parseStreamReplayGap in frontend to accept string | null bounds, safely resuming when bounds are null
- Add backend and frontend regression unit tests for queue clamping and null bounds replay gap

* docs(stream-bridge): bump config_version and document empty buffer replay gap behavior

* docs: document nullable gap bounds and sync helm config_version to 37
2026-08-29 17:21:29 +08:00

476 lines
16 KiB
TypeScript

"use client";
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
import { getLangGraphBaseURL } from "../config";
import { isStaticWebsiteOnly } from "../static-mode";
import {
loadStaticDemoThread,
loadStaticDemoThreads,
staticDemoThreadState,
} from "../threads/static-demo";
import type { AgentThreadState } from "../threads/types";
import { isStateChangingMethod, readCsrfCookie } from "./fetcher";
import { sanitizeRunStreamOptions } from "./stream-mode";
/**
* SDK ``onRequest`` hook that mints the ``X-CSRF-Token`` header from the
* live ``csrf_token`` cookie just before each outbound fetch.
*
* Reading the cookie per-request (rather than baking it into the SDK's
* ``defaultHeaders`` at construction) handles login / logout / password
* change cookie rotation transparently. Both the ``/api/langgraph/*`` SDK
* path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth``
* share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so
* the contract stays in lockstep.
*/
function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit {
if (!isStateChangingMethod(init.method ?? "GET")) {
return init;
}
const token = readCsrfCookie();
if (!token) return init;
const headers = new Headers(init.headers);
if (!headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", token);
}
return { ...init, headers };
}
// Run statuses that have reached a terminal state where no further streaming
// is possible. Reconnecting (``joinStream``) to such a run either 409s or, once
// the backend's in-memory stream bridge is reaped (``worker.py`` calls
// ``publish_end`` unconditionally, including for interrupted runs, then reaps
// the bridge after 60s), blocks forever on a drained condition variable —
// pinning ``isLoading`` true so the submit button stays a stop button and the
// first message after a reload never sends. The ``joinStream`` wrapper below
// short-circuits these *before* joining.
//
// ``interrupted`` is included because in DeerFlow it is only ever written by
// ``RunManager.cancel()`` (a user-initiated stop); the resumable human-in-the-
// loop path uses ``Command(goto=END)`` (``ClarificationMiddleware``), which
// ends the run as ``success``, not ``interrupted``. So an interrupted run has
// nothing left to stream — its state lives in the checkpoint, fetched
// independently by ``useThreadHistory``, and resuming means a fresh ``submit``.
//
// ``error``/``timeout`` are terminal too, so a reload within the ~60s
// bridge-reap window no longer replays the buffered error event through
// ``onError`` — the transient error toast (``getStreamErrorMessage``) is
// dropped. The persisted error state still loads from the checkpoint via
// ``useThreadHistory``, so only the toast is lost; that is intentional, since
// surfacing a stale error toast on every reload is noise rather than signal.
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// This is a rejoin budget: the original stream is not counted, so exhausting
// five recovery attempts can consume six streams in total.
const MAX_STREAM_GAP_RECOVERIES = 5;
export type StreamReplayGapData = {
code: "stream_replay_gap";
run_id: string;
requested_event_id: string | null;
earliest_available_event_id: string | null;
latest_available_event_id: string | null;
recovery: "reload_durable_state";
};
type StreamPart = {
id?: string;
event: string;
data: unknown;
};
export class StreamReplayGapError extends Error {
constructor(
readonly gap: StreamReplayGapData,
readonly recoveryAttempts: number,
readonly recoveryCause?: unknown,
) {
super(
`Unable to recover SSE history after ${recoveryAttempts} attempts (requested ${gap.requested_event_id ?? "initial stream"}, earliest ${gap.earliest_available_event_id ?? "none"})`,
);
this.name = "StreamReplayGapError";
}
}
function parseStreamReplayGap(data: unknown): StreamReplayGapData {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid stream replay gap payload.");
}
const value = data as Record<string, unknown>;
const requestedEventId = value.requested_event_id;
const earliestAvailableEventId = value.earliest_available_event_id;
const latestAvailableEventId = value.latest_available_event_id;
if (
value.code !== "stream_replay_gap" ||
typeof value.run_id !== "string" ||
(requestedEventId !== null && typeof requestedEventId !== "string") ||
(earliestAvailableEventId !== null &&
typeof earliestAvailableEventId !== "string") ||
(latestAvailableEventId !== null &&
typeof latestAvailableEventId !== "string") ||
value.recovery !== "reload_durable_state"
) {
throw new Error("Invalid stream replay gap payload.");
}
return value as StreamReplayGapData;
}
/**
* Shared matcher for the gateway's 409 conflict responses. The SDK surfaces
* non-2xx responses as ``HTTPError { status, message }`` where ``message`` is
* ``"HTTP 409: {\"detail\":\"...\"}"``, so a 409 may be detected either via the
* numeric ``status`` or a substring of ``message``.
*
* Every passed ``needles`` substring must be present; this AND semantics is what
* lets a caller distinguish sibling conflict branches by phrase (e.g. the
* terminal-state cancel branch from the still-active-on-another-worker branch).
*
* Match strings until the API exposes a structured error code; the source of
* truth is ``_cancel_conflict_detail`` / the store-only response in
* ``backend/app/gateway/routers/thread_runs.py``.
*/
function isRunConflictError(error: unknown, ...needles: string[]): boolean {
const status =
typeof error === "object" && error !== null
? Reflect.get(error, "status")
: undefined;
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: typeof error === "object" && error !== null
? String(Reflect.get(error, "message") ?? "")
: "";
return (
(status === 409 || message.includes("HTTP 409")) &&
needles.every((needle) => message.includes(needle))
);
}
// Store-only run cannot be streamed (no in-memory stream bridge on this
// worker): reconnect has nothing to rejoin.
export function isInactiveRunStreamError(error: unknown): boolean {
return isRunConflictError(
error,
"not active on this worker",
"cannot be streamed",
);
}
/**
* Matches the gateway's terminal-state cancel conflict, raised by
* ``_cancel_conflict_detail`` in ``backend/app/gateway/routers/thread_runs.py``
* as ``Run X is not cancellable (status: success|error|timeout)`` when
* ``RunManager.cancel`` refuses a run that already finished.
*
* The sibling ``"not active on this worker and cannot be cancelled"`` branch
* (run still pending/running on another worker in a multi-instance deploy) is
* intentionally NOT matched — that is a real cancel failure on a live run and
* must stay visible. Only the terminal-state branch is a true no-op.
*/
export function isRunNotCancellableError(error: unknown): boolean {
return isRunConflictError(error, "is not cancellable");
}
/**
* Preflight a reconnect: if the run already reached a terminal state, there is
* nothing to rejoin. Returns ``true`` when the caller should skip the
* underlying ``joinStream`` so the SDK's ``onSuccess`` path runs and
* ``isLoading`` flips back to false — instead of blocking forever on a drained
* stream bridge.
*
* Any error (404 for an evicted record, network blip, auth hiccup, …) falls
* back to the original join so a legitimately active reconnect is never
* silently suppressed.
*/
async function shouldSkipReconnect(
client: LangGraphClient,
threadId: string,
runId: string,
): Promise<boolean> {
try {
const run = await client.runs.get(threadId, runId);
return TERMINAL_RUN_STATUSES.has(run.status);
} catch {
return false;
}
}
export function clearReconnectRun(
threadId: string | null | undefined,
runId: string,
): void {
if (typeof window === "undefined" || !threadId) return;
const key = `lg:stream:${threadId}`;
try {
const storage = window.sessionStorage;
if (storage.getItem(key) === runId) {
storage.removeItem(key);
}
} catch {
// Ignore storage access failures so reconnect cleanup never throws.
}
}
function rememberReconnectRun(
threadId: string | null | undefined,
runId: string,
): void {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(`lg:stream:${threadId}`, runId);
} catch {
// Ignore storage access failures so gap recovery remains usable.
}
}
async function* recoverStreamReplayGaps({
client,
threadId,
expectedRunId,
initialStream,
resume,
}: {
client: LangGraphClient;
threadId: string | null | undefined;
expectedRunId: () => string | undefined;
initialStream: AsyncIterable<StreamPart>;
resume: (runId: string, lastEventId?: string) => AsyncIterable<StreamPart>;
}): AsyncGenerator<StreamPart> {
let stream = initialStream;
let recoveryAttempts = 0;
while (true) {
let gap: StreamReplayGapData | undefined;
for await (const entry of stream) {
if (entry.event === "gap") {
gap = parseStreamReplayGap(entry.data);
break;
}
yield entry;
}
if (!gap) {
return;
}
const runId = expectedRunId() ?? gap.run_id;
if (!threadId || gap.run_id !== runId) {
throw new Error(
"Stream replay gap does not match the active thread run.",
);
}
if (recoveryAttempts >= MAX_STREAM_GAP_RECOVERIES) {
throw new StreamReplayGapError(gap, recoveryAttempts);
}
recoveryAttempts += 1;
// The SDK would otherwise ignore an unknown `gap` event and report a
// normal finish. Surface a custom control event to DeerFlow's hook, reload
// durable values, then resume after the retained tail when it exists
// (or rejoin without a cursor if the buffer is empty).
clearReconnectRun(threadId, runId);
yield {
event: "custom",
data: { type: "stream_replay_gap", ...gap },
};
const durableState = await client.threads
.getState(threadId)
.catch((error: unknown) => {
throw new StreamReplayGapError(gap, recoveryAttempts, error);
});
if (durableState.values != null) {
yield { event: "values", data: durableState.values };
}
rememberReconnectRun(threadId, runId);
stream = resume(runId, gap.latest_available_event_id ?? undefined);
}
}
async function* handleInactiveRunStream({
threadId,
expectedRunId,
stream,
}: {
threadId: string | null | undefined;
expectedRunId: () => string | undefined;
stream: AsyncIterable<StreamPart>;
}): AsyncGenerator<StreamPart> {
try {
yield* stream;
} catch (error) {
const runId = expectedRunId();
if (runId && isInactiveRunStreamError(error)) {
clearReconnectRun(threadId, runId);
return;
}
throw error;
}
}
function createCompatibleClient(isMock?: boolean): LangGraphClient {
if (isStaticWebsiteOnly() && !isMock) {
return createStaticClient();
}
const apiUrl = getLangGraphBaseURL(isMock);
const client = new LangGraphClient({
apiUrl,
onRequest: injectCsrfHeader,
});
const originalRunStream = client.runs.stream.bind(client.runs);
const originalJoinStream = client.runs.joinStream.bind(client.runs);
// Preserve the SDK's lazy AsyncIterable contract. Its StreamManager consumes
// this return value with `for await`, so run creation still starts on first
// iteration rather than when `runs.stream()` is called.
client.runs.stream = async function* (threadId, assistantId, payload) {
const sanitizedPayload = sanitizeRunStreamOptions(payload);
const originalOnRunCreated = sanitizedPayload?.onRunCreated;
let runId: string | undefined;
const initialStream = originalRunStream(threadId, assistantId, {
...sanitizedPayload,
onRunCreated(meta) {
runId = meta.run_id;
originalOnRunCreated?.(meta);
},
});
const recoveredStream = recoverStreamReplayGaps({
client,
threadId,
expectedRunId: () => runId,
initialStream,
resume: (resolvedRunId, lastEventId) => {
// Keep the recovery run id available to the shared inactive-stream
// handler even if the SDK omitted its onRunCreated callback.
runId = resolvedRunId;
return originalJoinStream(threadId, resolvedRunId, {
lastEventId,
signal: sanitizedPayload?.signal,
streamMode: sanitizedPayload?.streamMode,
});
},
});
yield* handleInactiveRunStream({
threadId,
expectedRunId: () => runId,
stream: recoveredStream,
});
} as typeof client.runs.stream;
const originalCancel = client.runs.cancel.bind(client.runs);
client.runs.cancel = (async (threadId, runId, wait, action, options) => {
try {
return await originalCancel(threadId, runId, wait, action, options);
} catch (error) {
if (isRunNotCancellableError(error)) {
// The run already reached a terminal state, so cancelling it is a
// no-op. Swallow the 409 so a stop click during the finish window
// (backend flipped to ``success`` but the SSE stream hasn't drained)
// doesn't surface as an unhandled rejection, and clear the now-stale
// reconnect key. clearReconnectRun only removes the key when it still
// matches this runId, so a newer run's key is never touched.
clearReconnectRun(threadId, runId);
return;
}
throw error;
}
}) as typeof client.runs.cancel;
client.runs.joinStream = async function* (threadId, runId, options) {
// Short-circuit reconnects to runs that have already finished: otherwise a
// reload after the backend's stream bridge is reaped blocks forever on a
// drained condition variable, pinning ``isLoading`` true so the first
// post-reload message is routed to ``stop`` instead of ``submit``.
if (threadId && (await shouldSkipReconnect(client, threadId, runId))) {
clearReconnectRun(threadId, runId);
return;
}
const sanitizedOptions = sanitizeRunStreamOptions(options);
yield* handleInactiveRunStream({
threadId,
expectedRunId: () => runId,
stream: recoverStreamReplayGaps({
client,
threadId,
expectedRunId: () => runId,
initialStream: originalJoinStream(threadId, runId, sanitizedOptions),
resume: (resolvedRunId, lastEventId) =>
originalJoinStream(threadId, resolvedRunId, {
...sanitizedOptions,
lastEventId,
}),
}),
});
} as typeof client.runs.joinStream;
return client;
}
function createStaticClient(): LangGraphClient {
const apiUrl =
typeof window === "undefined"
? "http://localhost:3000"
: window.location.origin;
const client = new LangGraphClient({ apiUrl });
client.threads.search = (async (query) => {
return loadStaticDemoThreads(query);
}) as typeof client.threads.search;
client.threads.get = (async (threadId) => {
return loadStaticDemoThread(threadId);
}) as typeof client.threads.get;
client.threads.getState = (async (threadId) => {
return staticDemoThreadState(await loadStaticDemoThread(threadId));
}) as typeof client.threads.getState;
client.threads.getHistory = (async (threadId) => {
return [staticDemoThreadState(await loadStaticDemoThread(threadId))];
}) as typeof client.threads.getHistory;
client.threads.update = (async (threadId) => {
return loadStaticDemoThread(threadId);
}) as typeof client.threads.update;
client.runs.list = (async () => []) as typeof client.runs.list;
client.runs.stream = async function* () {
/* empty */
} as typeof client.runs.stream;
client.runs.joinStream = async function* () {
/* empty */
} as typeof client.runs.joinStream;
return client as LangGraphClient<AgentThreadState>;
}
const _clients = new Map<string, LangGraphClient>();
export function getAPIClient(isMock?: boolean): LangGraphClient {
const cacheKey = isMock ? "mock" : "default";
let client = _clients.get(cacheKey);
if (!client) {
client = createCompatibleClient(isMock);
_clients.set(cacheKey, client);
}
return client;
}