UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,033 lines (1,032 loc) 46.9 kB
import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js"; import { O as parseAgentSessionKey, S as isAcpSessionKey, T as isSubagentSessionKey, k as parseCronRunScopeSuffix } from "./session-key-BnWWjqNc.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { u as normalizeVerboseLevel } from "./thinking.shared-bHYuuc1L.js"; import "./io-bdCzpGWJ.js"; import { n as logError } from "./logger-DwECwNVZ.js"; import { t as setSafeTimeout } from "./timer-delay-x5n129Nx.js"; import { c as getAgentEventLifecycleGeneration } from "./agent-events-CoxiItUi.js"; import { c as getAgentRunContext, l as getAgentRunContextOwnerStatus } from "./agent-run-registry-CKYKdfNd.js"; import { Nm as projectChatErrorDetail } from "./src-BiL5aQto.js"; import { o as resolveAssistantEventPhase } from "./chat-message-content-Dws_XUEQ.js"; import "./thinking-DterdXhr.js"; import { c as isDefinitiveRunLifecycle, i as buildAgentRunTerminalOutcomeFromLifecycleEvent, o as classifyAgentRunTerminalOutcome, t as AGENT_RUN_TERMINAL_RETRY_GRACE_MS } from "./agent-run-terminal-outcome-CigeY75d.js"; import { s as isTimeoutError } from "./error-Bb_OF8ag.js"; import { s as isActiveEmbeddedRunId } from "./runs-Cb42qain.js"; import { u as stripHeartbeatToken } from "./heartbeat-ofet59jF.js"; import { f as resolveFailoverReasonFromError } from "./failover-error-BkNxlp8A.js"; import { c as resolveToolSearchCodeDisplayTarget } from "./tool-display-CzOArp4x.js"; import { f as normalizeAgentPlanSteps } from "./streaming-B_uLiLJZ.js"; import { a as loadGatewaySessionLifecycleSnapshot } from "./session-utils-list-B0k8KJn5.js"; import { n as resolveSessionSubscriptionKeys } from "./session-subscription-keys-BwUDpvzj.js"; import { i as tryResolveSessionCompatibilityOwnerAgentId } from "./session-request-agent-CCRSEGCB.js"; import { i as loadGatewaySessionEntryReadOnly } from "./session-utils-store-CInT2loy.js"; import "./session-utils-Cai0_C6U.js"; import { r as readToolValidationErrorSummary } from "./tool-error-summary-Bw_A4yhp.js"; import { a as createSessionMessageSubscriberRegistry, c as projectLiveAssistantBufferedText, d as shouldSuppressAssistantEventForLiveChat, i as createSessionEventSubscriberRegistry, l as resolveAssistantLiveChatInput, n as createChatAbortMarker, o as isChatAbortMarkerCurrent, r as createChatRunState, u as resolveMergedAssistantText } from "./server-chat-state-CwKZZaYd.js"; import { t as resolveHeartbeatVisibility } from "./heartbeat-visibility-UVwDVBL7.js"; import { t as formatForLog } from "./ws-log-Dkg9wKNU.js"; import { a as persistGatewaySessionLifecycleEvent, r as isRestartRecoveryLifecycleEvent, s as isAgentLifecycleYieldedWaiting } from "./session-lifecycle-state-DCd4j8Bd.js"; import { n as buildGatewaySessionSnapshot } from "./session-event-payload-COi33-eM.js"; import { t as hasSessionChangeReceivers } from "./session-change-receivers-BH6Ki2zl.js"; import { i as roundedChatSendTimingMs } from "./chat-server-timing-BdF8Qze8.js"; import { performance } from "node:perf_hooks"; //#region src/gateway/server-chat.ts const CHAT_STATE_BY_TERMINAL_CLASSIFICATION = { success: "done", timeout: "error", cancellation: "aborted", failure: "error" }; const RESTART_RECOVERY_LIFECYCLE_PHASES = /* @__PURE__ */ new Set([ "start", "end", "error" ]); function readChatRunStartupPhase(value) { switch (value) { case "preparing_workspace": case "naming_worktree": case "creating_worktree": case "running_setup": case "provisioning_environment": case "preparing_context": case "starting_model": return value; default: return; } } function projectToolSearchCodeEventForChannelPayload(payload) { const data = payload.data; if (!data || typeof data !== "object") return payload; const record = data; if (record.name !== "tool_search_code") return payload; const target = resolveToolSearchCodeDisplayTarget(record.args); if (!target) return payload; const projectedName = target.displayToolName ?? target.toolName; if (!projectedName || projectedName === "tool_search_code") return payload; const projectedData = { ...record, name: projectedName }; if (target.displayArgs) projectedData.args = target.displayArgs; else if (target.detail) projectedData.args = { detail: target.detail }; if (target.bridgeVerb) { projectedData.bridgeToolName = "tool_search_code"; projectedData.bridgeTargetToolName = target.toolName; projectedData.bridgeVerb = target.bridgeVerb; } return { ...payload, data: projectedData }; } function resolveHeartbeatContext(runId, sourceRunId) { const primary = getAgentRunContext(runId); if (primary?.isHeartbeat) return primary; if (sourceRunId && sourceRunId !== runId) { const source = getAgentRunContext(sourceRunId); if (source?.isHeartbeat) return source; } return primary; } /** * Check if heartbeat ACK/noise should be hidden from interactive chat surfaces. */ function shouldHideHeartbeatChatOutput(runId, sourceRunId) { if (!resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat) return false; try { const cfg = getRuntimeConfig(); return !resolveHeartbeatVisibility({ cfg, channel: "webchat" }).showOk; } catch { return true; } } function shouldSuppressHeartbeatToolEvents(runId, sourceRunId) { return Boolean(resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat); } function shouldMirrorAssistantEventToHiddenSessionMessages(data) { if (!data || typeof data !== "object") return false; const record = data; const hasText = typeof record.text === "string" && record.text.length > 0; const hasDelta = typeof record.delta === "string" && record.delta.length > 0; if (!hasText && !hasDelta) return false; return resolveAssistantEventPhase(data) === "commentary"; } function shouldMirrorAgentEventToHiddenSessionMessages(evt) { return evt.stream === "thinking" || evt.stream === "approval" || evt.stream === "lifecycle"; } function normalizeHeartbeatChatFinalText(params) { if (!shouldHideHeartbeatChatOutput(params.runId, params.sourceRunId)) return { suppress: false, text: params.text }; const stripped = stripHeartbeatToken(params.text, { mode: "heartbeat", maxAckChars: 300 }); if (!stripped.didStrip) return { suppress: false, text: params.text }; if (stripped.shouldSkip) return { suppress: true, text: "" }; return { suppress: false, text: stripped.text }; } const LIVE_TEXT_PACING_MS = 75; const CHAT_ERROR_KINDS = /* @__PURE__ */ new Set([ "refusal", "timeout", "rate_limit", "context_length", "unknown" ]); const CHAT_ERROR_KIND_BY_FAILOVER_REASON = { auth: void 0, auth_permanent: void 0, format: void 0, rate_limit: "rate_limit", overloaded: "rate_limit", billing: void 0, server_error: void 0, timeout: void 0, tls_certificate: void 0, context_overflow: "context_length", model_not_found: void 0, session_expired: void 0, empty_response: void 0, no_error_details: void 0, unclassified: void 0, unknown: void 0 }; function readChatErrorKind(value) { return typeof value === "string" && CHAT_ERROR_KINDS.has(value) ? value : void 0; } function resolveChatErrorKindFromError(error) { if (error === void 0) return; const message = formatErrorMessage(error).toLowerCase(); if (message.includes("refusal") || message.includes("content_filter") || message.includes("sensitive") || message.includes("unhandled stop reason: refusal_policy")) return "refusal"; const reason = resolveFailoverReasonFromError(error); if (reason) { const errorKind = CHAT_ERROR_KIND_BY_FAILOVER_REASON[reason]; if (errorKind) return errorKind; } return isTimeoutError(error) ? "timeout" : void 0; } function excludeConnIds(connIds, excludedConnIds) { if (!excludedConnIds || excludedConnIds.size === 0 || connIds.size === 0) return connIds; const filtered = /* @__PURE__ */ new Set(); for (const connId of connIds) if (!excludedConnIds.has(connId)) filtered.add(connId); return filtered; } function resolveBroadcastDelta(params) { const previous = params.previousBroadcastText; if (previous === void 0) return params.text ? { deltaText: params.text } : void 0; if (!params.text.startsWith(previous)) return { deltaText: params.text, replace: true }; const deltaText = params.text.slice(previous.length); return deltaText ? { deltaText } : void 0; } function mergeAgentTextPayload(previous, next) { const payload = next; const delta = previous.data.delta; const nextDelta = payload.data.delta; return payload.stream !== "item" && typeof delta === "string" && typeof nextDelta === "string" ? { ...payload, data: { ...payload.data, delta: `${delta}${nextDelta}` } } : payload; } function mergeChatTextPayload(previous, next) { const payload = next; return { ...payload, deltaText: `${previous.deltaText}${payload.deltaText}` }; } function cancelPendingLiveTextFlush(run, stream) { const pending = run.pendingTextFlushes?.[stream]; if (!pending) return; clearTimeout(pending.timer); delete run.pendingTextFlushes?.[stream]; if (run.pendingTextFlushes && Object.keys(run.pendingTextFlushes).length === 0) delete run.pendingTextFlushes; } function scheduleLiveTextFlush(run, stream, delayMs, flush) { const pendingFlushes = run.pendingTextFlushes ??= {}; const existing = pendingFlushes[stream]; if (existing) { existing.flush = flush; return; } const timer = setSafeTimeout(() => { const pending = run.pendingTextFlushes?.[stream]; if (!pending || pending.timer !== timer) return; cancelPendingLiveTextFlush(run, stream); pending.flush(); }, delayMs); timer.unref?.(); pendingFlushes[stream] = { timer, flush }; } function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSession, agentRunSeq, chatRunState, resolveSessionKeyForRun, clearAgentRunContext, toolEventRecipients, sessionEventSubscribers, sessionMessageSubscribers, loadGatewaySessionLifecycleSnapshotForEvent = loadGatewaySessionLifecycleSnapshot, persistGatewaySessionLifecycleEventForEvent = persistGatewaySessionLifecycleEvent, lifecycleErrorRetryGraceMs = AGENT_RUN_TERMINAL_RETRY_GRACE_MS, isChatSendRunActive = () => false, clearTrackedActiveRun, settleTrackedTerminal, trackTrackedRunTerminalPersistence, resolveActiveLifecycleGenerationForRun = () => void 0, updateRunToolErrorSummary, resolveSessionActiveRunState }) { const shouldProcessOwnedEvent = (evt) => { const claimId = evt.contextClaimId; if (!claimId) return true; const lifecycleGeneration = evt.lifecycleGeneration; if (!lifecycleGeneration || lifecycleGeneration !== getAgentEventLifecycleGeneration()) return false; return getAgentRunContextOwnerStatus(evt.runId, claimId, lifecycleGeneration) === "active"; }; const clearRunContextForEvent = (evt) => { if (evt.contextClaimId) { clearAgentRunContext(evt.runId, evt.lifecycleGeneration, evt.contextClaimId); return; } clearAgentRunContext(evt.runId); }; const resolveEventSession = (evt) => { const chatLink = evt.contextClaimId ? void 0 : chatRunState.registry.peek(evt.runId); const sessionAgentId = chatLink?.agentId ?? evt.agentId; const eventSessionKey = evt.deliverySessionKey ?? (typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : void 0); return { chatLink, sessionAgentId, eventSessionKey, sessionKey: chatLink?.sessionKey ?? eventSessionKey ?? getAgentRunContext(evt.runId)?.sessionKey ?? resolveSessionKeyForRun(evt.runId, sessionAgentId ? { agentId: sessionAgentId } : void 0) }; }; const pendingTerminalLifecycleErrors = /* @__PURE__ */ new Map(); const liveTextDelivery = (runId, coalesce, isCurrent) => { const run = coalesce ? chatRunState.getOrCreate(runId) : chatRunState.runs.get(runId); const group = run && (coalesce ? run.liveTextGroup ??= new AbortController() : run.liveTextGroup); return group ? { group: group.signal, coalesce, isCurrent: coalesce ? isCurrent : void 0 } : void 0; }; const cancelPendingChatDeltaFlush = (clientRunId) => { const record = chatRunState.runs.get(clientRunId); if (record) cancelPendingLiveTextFlush(record, "chat"); }; const clearPendingTerminalLifecycleError = (runId, lifecycleGeneration) => { const pending = pendingTerminalLifecycleErrors.get(runId); if (!pending) return; if (lifecycleGeneration && pending.event.lifecycleGeneration && lifecycleGeneration !== pending.event.lifecycleGeneration) return; clearTimeout(pending.timer); pendingTerminalLifecycleErrors.delete(runId); }; const resolveRestartRecoveryLifecycleState = (sessionKey, agentId, event) => { try { const { entry } = loadGatewaySessionEntryReadOnly(sessionKey, { ...agentId ? { agentId } : {}, clone: false }); return { suppress: isRestartRecoveryLifecycleEvent({ entry, event }) }; } catch { return { suppress: false }; } }; const spawnedByCache = /* @__PURE__ */ new Map(); const resolveSpawnedBy = (sessionKey) => { if (spawnedByCache.has(sessionKey)) return spawnedByCache.get(sessionKey); const isDashboardSession = parseAgentSessionKey(sessionKey)?.rest.startsWith("dashboard:") === true; if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey) && !isDashboardSession) return null; let result = null; try { result = loadGatewaySessionLifecycleSnapshotForEvent(sessionKey).row?.spawnedBy ?? null; } catch {} spawnedByCache.set(sessionKey, result); return result; }; const buildSessionEventSnapshot = (sessionKey, evt, agentId, includeActiveRunState = false, lifecycleProjection = false) => { const { lifecycleRunId, row } = loadGatewaySessionLifecycleSnapshotForEvent(sessionKey, agentId ? { agentId } : void 0); const activeRunState = includeActiveRunState ? resolveSessionActiveRunState?.({ requestedKey: sessionKey, canonicalKey: row?.key ?? sessionKey, ...row?.sessionId ? { sessionId: row.sessionId } : {}, ...agentId ? { agentId } : {} }) : void 0; return buildGatewaySessionSnapshot({ sessionRow: row, agentId, includeSession: true, lifecycle: lifecycleProjection, event: evt, lifecycleRunId, activeRunState }); }; const resolveSessionDeliveryKeys = (sessionKey, agentId) => { if (sessionKey.trim().toLowerCase() !== "global") return [sessionKey]; const compatibilityOwnerAgentId = tryResolveSessionCompatibilityOwnerAgentId(getRuntimeConfig(), sessionKey); const deliveryAgentId = agentId ?? compatibilityOwnerAgentId; return deliveryAgentId ? resolveSessionSubscriptionKeys(sessionKey, deliveryAgentId, compatibilityOwnerAgentId) : []; }; const sendNodeSessionPayloadForAgent = (sessionKey, event, payload, agentId) => { for (const deliverySessionKey of resolveSessionDeliveryKeys(sessionKey, agentId)) nodeSendToSession(deliverySessionKey, event, payload); }; const emitFirstAssistantChatSendTiming = (chatLink) => { const timing = chatLink?.chatSendTiming; if (!timing || timing.firstAssistantEventSent) return; timing.firstAssistantEventSent = true; const nowMs = performance.now(); broadcastToConnIds("chat.send_timing", { phase: "first-assistant-event", runId: chatLink.clientRunId, sessionKey: chatLink.sessionKey, ...chatLink.agentId ? { agentId: chatLink.agentId } : {}, ackToPhaseMs: roundedChatSendTimingMs(nowMs - timing.ackedAtMs), receivedToPhaseMs: roundedChatSendTimingMs(nowMs - timing.receivedAtMs), ...timing.dispatchStartedAtMs !== void 0 ? { dispatchStartedToPhaseMs: roundedChatSendTimingMs(nowMs - timing.dispatchStartedAtMs) } : {} }, /* @__PURE__ */ new Set([timing.connId]), { dropIfSlow: true }); }; const finalizeLifecycleEvent = (evt, opts) => { if (!shouldProcessOwnedEvent(evt)) return; const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; if (lifecyclePhase !== "end" && lifecyclePhase !== "error") return; const currentRunContext = getAgentRunContext(evt.runId); const activeLifecycleGeneration = resolveActiveLifecycleGenerationForRun(evt.runId); const currentLifecycleGeneration = activeLifecycleGeneration ?? currentRunContext?.lifecycleGeneration; const { chatLink, sessionAgentId, eventSessionKey, sessionKey } = resolveEventSession(evt); const isControlUiVisible = evt.controlUiVisible ?? currentRunContext?.isControlUiVisible ?? true; const projectSessionLifecycle = evt.projectSessionLifecycle ?? currentRunContext?.projectSessionLifecycle ?? true; const projectSessionMessages = evt.projectSessionMessages ?? currentRunContext?.projectSessionMessages ?? true; const restartRecoverySessionKey = eventSessionKey ?? sessionKey; const restartRecoveryAgentId = evt.agentId ?? sessionAgentId; const clientRunId = chatLink?.clientRunId ?? evt.runId; const eventRunId = chatLink?.clientRunId ?? evt.runId; const isAborted = isChatAbortMarkerCurrent(chatRunState.runs.get(clientRunId)?.abortMarker, chatLink) || isChatAbortMarkerCurrent(chatRunState.runs.get(evt.runId)?.abortMarker, chatLink); const lifecycleAborted = evt.data?.aborted === true; const replyDispatchOwnsCompletion = evt.data?.completionSource === "reply-dispatch"; const deliverySessionKeys = sessionKey ? resolveSessionDeliveryKeys(sessionKey, sessionAgentId) : []; const restartRecoveryState = opts?.restartRecoveryState ?? (restartRecoverySessionKey ? resolveRestartRecoveryLifecycleState(restartRecoverySessionKey, restartRecoveryAgentId, evt) : void 0); const suppressRestartRecoveryProjection = opts?.suppressRestartRecoveryProjection === true || Boolean(evt.lifecycleGeneration && activeLifecycleGeneration && evt.lifecycleGeneration !== activeLifecycleGeneration) || restartRecoveryState?.suppress === true; if (suppressRestartRecoveryProjection && Boolean(evt.lifecycleGeneration && currentLifecycleGeneration && evt.lifecycleGeneration !== currentLifecycleGeneration)) return; clearPendingTerminalLifecycleError(evt.runId, evt.lifecycleGeneration); let terminalPersistence; if (!replyDispatchOwnsCompletion && !suppressRestartRecoveryProjection && sessionKey && (isControlUiVisible || projectSessionMessages && deliverySessionKeys.some((deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0))) { if (!isAborted) { const finished = chatLink ? chatRunState.registry.shift(evt.runId) : void 0; const terminalSessionKey = finished?.sessionKey ?? sessionKey; const terminalRunId = finished?.clientRunId ?? eventRunId; const terminalAgentId = finished?.agentId ?? sessionAgentId; const terminalOutcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ phase: lifecyclePhase, data: evt.data, endedAt: evt.data?.endedAt ?? evt.ts }); const yieldedWaiting = isAgentLifecycleYieldedWaiting({ phase: lifecyclePhase, yielded: evt.data?.yielded, livenessState: evt.data?.livenessState, stopReason: terminalOutcome.stopReason, aborted: lifecycleAborted, status: evt.data?.status, timeoutPhase: evt.data?.timeoutPhase, error: evt.data?.error }); const terminalClassification = classifyAgentRunTerminalOutcome(terminalOutcome); const terminalState = CHAT_STATE_BY_TERMINAL_CLASSIFICATION[terminalClassification]; if (!(opts?.skipChatErrorFinal && terminalState === "error")) emitChatTerminal(terminalSessionKey, terminalRunId, evt.runId, evt.seq, terminalState, terminalOutcome.error ?? evt.data?.error, terminalOutcome.stopReason, terminalClassification === "timeout" ? "timeout" : readChatErrorKind(evt.data?.errorKind) ?? resolveChatErrorKindFromError(evt.data?.error), { agentId: terminalAgentId, controlUiVisible: isControlUiVisible, firstAssistantTimingEntry: finished, abortErrorMessage: readToolValidationErrorSummary(evt.data?.toolErrorSummary), yielded: yieldedWaiting ? true : void 0, errorObservation: evt.data?.errorObservation }); } else if (chatLink) chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); } toolEventRecipients.markFinal(evt.runId); if (!replyDispatchOwnsCompletion) { chatRunState.clearRun(clientRunId); if (suppressRestartRecoveryProjection && chatLink) chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); if (!evt.contextClaimId) clearRunContextForEvent(evt); agentRunSeq.delete(evt.runId); agentRunSeq.delete(clientRunId); } if (sessionKey) { clearTrackedActiveRun?.({ runId: evt.runId, clientRunId, sessionKey }); if (!suppressRestartRecoveryProjection && projectSessionLifecycle) { const persistence = persistGatewaySessionLifecycleEventForEvent({ sessionKey, agentId: sessionAgentId, event: { ...evt, ...evt.contextClaimId ? { contextClaimId: evt.contextClaimId } : {}, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}, ...evt.lifecycleGeneration ? { lifecycleGeneration: evt.lifecycleGeneration } : {}, ...evt.mainSessionRestartRecovery === true ? { mainSessionRestartRecovery: true } : {} } }); terminalPersistence = persistence; trackTrackedRunTerminalPersistence?.({ runId: evt.runId, clientRunId, sessionKey, sessionId: evt.sessionId, persistence }); const broadcastSessionChange = (snapshotEvent) => { if (parseCronRunScopeSuffix(sessionKey).runId) return; const sessionEventConnIds = sessionEventSubscribers.getAll(); if (!hasSessionChangeReceivers(sessionEventConnIds)) return; broadcastToConnIds("sessions.changed", { sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, phase: lifecyclePhase, runId: evt.runId, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}, ts: evt.ts, ...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId, true, true) }, sessionEventConnIds, { dropIfSlow: true }); }; persistence.then(() => { settleTrackedTerminal?.({ runId: evt.runId, clientRunId, sessionKey }); broadcastSessionChange(); }).catch((err) => { logError(`gateway: terminal session persistence failed session=${formatForLog(sessionKey)} run=${formatForLog(evt.runId)} error=${formatForLog(err)}`); settleTrackedTerminal?.({ runId: evt.runId, clientRunId, sessionKey, persisted: false }); broadcastSessionChange(evt); }); } else settleTrackedTerminal?.({ runId: evt.runId, clientRunId, sessionKey, persisted: false }); } if (!replyDispatchOwnsCompletion && evt.contextClaimId) { if (terminalPersistence) { const clearOwnedRunContext = () => clearRunContextForEvent(evt); terminalPersistence.then(clearOwnedRunContext, clearOwnedRunContext); } else clearRunContextForEvent(evt); } }; const scheduleTerminalLifecycleError = (evt, opts) => { clearPendingTerminalLifecycleError(evt.runId); const timer = setSafeTimeout(() => { const pending = pendingTerminalLifecycleErrors.get(evt.runId); if (!pending || pending.timer !== timer) return; pendingTerminalLifecycleErrors.delete(evt.runId); finalizeLifecycleEvent(pending.event, pending.opts); }, lifecycleErrorRetryGraceMs); timer.unref?.(); pendingTerminalLifecycleErrors.set(evt.runId, { timer, event: evt, opts }); }; const broadcastChatDelta = (sessionKey, agentId, clientRunId, sourceRunId, seq, text, opts) => { cancelPendingChatDeltaFlush(clientRunId); const run = chatRunState.getOrCreate(clientRunId); const broadcastDelta = resolveBroadcastDelta({ text, previousBroadcastText: run.deltaLastBroadcastText }); if (!broadcastDelta) return; const now = Date.now(); run.deltaSentAt = now; run.deltaLastBroadcastText = text; const spawnedBy = resolveSpawnedBy(sessionKey); const payload = { runId: clientRunId, sessionKey, ...agentId ? { agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: "delta", deltaText: broadcastDelta.deltaText, ...broadcastDelta.replace ? { replace: true } : {}, message: { role: "assistant", content: [{ type: "text", text }], timestamp: now } }; emitFirstAssistantChatSendTiming(opts?.firstAssistantTimingEntry ?? chatRunState.registry.peek(sourceRunId)); sendLivePayload("chat", sessionKey, payload, { agentId, controlUiVisible: opts?.controlUiVisible ?? true, dropIfSlow: true, liveText: liveTextDelivery(clientRunId, broadcastDelta.replace ? void 0 : { key: JSON.stringify([ "chat", sessionKey, agentId, opts?.controlUiVisible ?? true ]), merge: mergeChatTextPayload }, run.bufferIsCurrent) }); }; const scheduleChatDeltaFlush = (sessionKey, agentId, clientRunId, sourceRunId, seq, delayMs, controlUiVisible) => { const run = chatRunState.getOrCreate(clientRunId); const flush = () => { if (run.bufferIsCurrent?.() === false) { chatRunState.clearRun(clientRunId); agentRunSeq.delete(sourceRunId); return; } const projected = chatRunState.resolveBuffer(clientRunId); if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) return; broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, projected.text, { controlUiVisible }); }; scheduleLiveTextFlush(run, "chat", delayMs, flush); }; const emitChatDelta = (sessionKey, agentId, clientRunId, sourceRunId, seq, input, opts) => { const run = chatRunState.getOrCreate(clientRunId); if (input.managedMediaUrls?.length) { const managedMediaUrls = run.managedMediaUrls ??= /* @__PURE__ */ new Set(); for (const url of input.managedMediaUrls) managedMediaUrls.add(url); delete run.bufferProjection; } const previousRawText = run.rawBuffer ?? ""; if (!input.itemId) delete run.assistantScope; else if (run.assistantScope?.itemId !== input.itemId) run.assistantScope = { itemId: input.itemId, prefix: input.replaceStream ? "" : previousRawText }; const mergedRawText = resolveMergedAssistantText({ previousText: previousRawText, nextText: input.text, nextDelta: input.delta, scope: run.assistantScope }); if (!mergedRawText && !previousRawText) return; const now = Date.now(); run.rawBuffer = mergedRawText; run.bufferIsCurrent = opts?.isCurrent; run.bufferUpdatedAt = now; if (!mergedRawText) { broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, "", opts); return; } if (run.deltaSentAt !== void 0) { scheduleChatDeltaFlush(sessionKey, agentId, clientRunId, sourceRunId, seq, LIVE_TEXT_PACING_MS - (now - run.deltaSentAt), opts?.controlUiVisible); return; } const projected = chatRunState.resolveBuffer(clientRunId); const mergedText = projected.text; if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) return; broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, mergedText, opts); }; const resolveBufferedChatTextState = (clientRunId, sourceRunId, options) => { const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({ runId: clientRunId, sourceRunId, text: chatRunState.resolveBuffer(clientRunId, { final: options?.final }).text.trim() }); const projected = projectLiveAssistantBufferedText(normalizedHeartbeatText.text.trim(), { suppressLeadFragments: options?.suppressLeadFragments }); return { text: projected.text.trim(), shouldSuppressSilent: normalizedHeartbeatText.suppress || projected.suppress }; }; const flushBufferedChatDeltaIfNeeded = (sessionKey, agentId, clientRunId, sourceRunId, seq, opts, resolved) => { cancelPendingChatDeltaFlush(clientRunId); const streamed = resolved ? projectLiveAssistantBufferedText(resolved.text, { suppressLeadFragments: true }) : void 0; const { text, shouldSuppressSilent } = streamed ? { text: streamed.text.trim(), shouldSuppressSilent: resolved?.shouldSuppressSilent || streamed.suppress } : resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: true }); const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(clientRunId, sourceRunId); if (!text || shouldSuppressSilent || shouldSuppressHeartbeatStreaming) return; broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, text, opts); }; const sendLivePayload = (event, sessionKey, payload, opts) => { const visible = opts?.controlUiVisible ?? true; const deliverySessionKeys = sessionKey ? resolveSessionDeliveryKeys(sessionKey, opts?.agentId) : void 0; const broadcastOpts = { dropIfSlow: event === "agent" && visible ? void 0 : opts?.dropIfSlow, sessionKeys: deliverySessionKeys, liveText: opts?.liveText ?? liveTextDelivery(payload.runId) }; if (visible) { broadcast(event, payload, broadcastOpts); if (sessionKey) sendNodeSessionPayloadForAgent(sessionKey, event, payload, opts?.agentId); return; } const recipients = /* @__PURE__ */ new Set(); for (const deliveryKey of deliverySessionKeys ?? []) for (const connId of sessionMessageSubscribers.get(deliveryKey)) recipients.add(connId); if (recipients.size > 0) broadcastToConnIds(event, payload, recipients, { ...broadcastOpts, sessionSubscriptionVerified: true }); }; const emitChatTerminal = (sessionKey, clientRunId, sourceRunId, seq, jobState, error, stopReason, errorKind, opts) => { const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { final: true, suppressLeadFragments: false }); flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId, clientRunId, sourceRunId, seq, opts, { text, shouldSuppressSilent }); const spawnedBy = resolveSpawnedBy(sessionKey); if (jobState !== "error") { const payload = { runId: clientRunId, sessionKey, ...opts?.agentId ? { agentId: opts.agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: jobState === "done" ? "final" : "aborted", ...jobState === "aborted" && opts?.abortErrorMessage ? { errorMessage: opts.abortErrorMessage } : {}, ...stopReason && { stopReason }, ...jobState === "done" && opts?.yielded ? { yielded: true } : {}, message: text && !shouldSuppressSilent ? { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now() } : void 0 }; sendLivePayload("chat", sessionKey, payload, opts); chatRunState.clearRun(clientRunId); return; } const errorDetail = projectChatErrorDetail(opts?.errorObservation); const payload = { runId: clientRunId, sessionKey, ...opts?.agentId ? { agentId: opts.agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: "error", errorMessage: error ? formatForLog(error) : void 0, ...errorKind && { errorKind }, ...errorDetail ? { errorDetail } : {}, ...stopReason && { stopReason } }; sendLivePayload("chat", sessionKey, payload, opts); chatRunState.clearRun(clientRunId); }; const sendAgentPayload = (sessionKey, payload, opts) => { const stream = opts?.coalesce ? resolveAgentTextThrottleStream(payload) : null; const liveText = liveTextDelivery(payload.runId, stream ? { key: JSON.stringify([ "agent", stream, payload.data.itemId, sessionKey, opts?.agentId, opts?.controlUiVisible ?? true ]), merge: mergeAgentTextPayload } : void 0, opts?.isCurrent); sendLivePayload("agent", sessionKey, payload, { ...opts, liveText }); }; const flushBufferedAgentDeltaIfNeeded = (clientRunId) => { const run = chatRunState.runs.get(clientRunId); if (run) cancelPendingLiveTextFlush(run, "agent"); const states = Object.values(run?.agentText ?? {}); states.sort((a, b) => (a.bufferedEvent?.payload.seq ?? 0) - (b.bufferedEvent?.payload.seq ?? 0)); for (const state of states) { const buffered = state.bufferedEvent; if (!buffered) continue; delete state.bufferedEvent; if (buffered.isCurrent?.() === false) continue; state.lastSentAt = Date.now(); sendAgentPayload(buffered.sessionKey, buffered.payload, { agentId: buffered.agentId, controlUiVisible: buffered.controlUiVisible, dropIfSlow: buffered.controlUiVisible === false, coalesce: true, isCurrent: buffered.isCurrent }); } }; const resolveAgentTextThrottleStream = (evt) => { if (evt.stream === "assistant" || evt.stream === "thinking") { const stream = evt.stream === "assistant" ? "assistant" : "thinking"; return typeof evt.data.delta === "string" || evt.data.replace === true ? stream : null; } const { kind, phase, status, itemId, progressText } = evt.data; return evt.stream === "item" && phase === "update" && (kind === "preamble" || kind === "answer_candidate" && status === "candidate") && typeof itemId === "string" && typeof progressText === "string" ? kind : null; }; const shouldCoalesceAgentTextEvent = (evt) => !(Array.isArray(evt.data.mediaUrls) && evt.data.mediaUrls.length > 0) && typeof evt.data.mediaUrl !== "string" && evt.data.replace !== true && (evt.stream === "item" || typeof evt.data.text === "string" && typeof evt.data.delta === "string" && evt.data.delta.length > 0 && (evt.stream !== "assistant" || !shouldSuppressAssistantEventForLiveChat(evt.data))); const sendOrBufferAgentTextEvent = (clientRunId, next) => { const { payload } = next; const stream = resolveAgentTextThrottleStream(payload); const now = Date.now(); const run = stream ? chatRunState.getOrCreate(clientRunId) : void 0; const state = run && stream ? (run.agentText ??= {})[stream] ??= {} : void 0; const last = state?.lastSentAt; const previous = state?.bufferedEvent; if (run && state && last !== void 0 && shouldCoalesceAgentTextEvent(payload) && (!previous || previous.payload.data.itemId === payload.data.itemId && previous.sessionKey === next.sessionKey && previous.agentId === next.agentId && previous.controlUiVisible === next.controlUiVisible && previous.isCurrent?.() !== false)) { state.bufferedEvent = { ...next, payload: previous ? mergeAgentTextPayload(previous.payload, payload) : payload }; scheduleLiveTextFlush(run, "agent", LIVE_TEXT_PACING_MS - (now - last), () => flushBufferedAgentDeltaIfNeeded(clientRunId)); return; } flushBufferedAgentDeltaIfNeeded(clientRunId); sendAgentPayload(next.sessionKey, payload, { agentId: next.agentId, controlUiVisible: next.controlUiVisible, dropIfSlow: next.controlUiVisible === false }); if (state) state.lastSentAt = now; }; const resolveToolVerboseLevel = (runId, sessionKey) => { const runContext = getAgentRunContext(runId); const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); if (!sessionKey) return runVerbose ?? "off"; try { const { cfg, entry } = loadGatewaySessionEntryReadOnly(sessionKey); const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel); const sessionUpdatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : void 0; const sessionChangedAfterRunStarted = sessionUpdatedAt !== void 0 && runContext?.registeredAt !== void 0 && sessionUpdatedAt >= runContext.registeredAt; if (sessionVerbose && (!runVerbose || sessionChangedAfterRunStarted)) return sessionVerbose; if (runVerbose) return runVerbose; return normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault) ?? "off"; } catch { return runVerbose ?? "off"; } }; const handleEvent = (event) => { const evt = event; const isCurrent = () => shouldProcessOwnedEvent(evt); if (!isCurrent()) return; const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; const { chatLink, sessionAgentId, eventSessionKey, sessionKey } = resolveEventSession(evt); const runContext = getAgentRunContext(evt.runId); const activeLifecycleGeneration = resolveActiveLifecycleGenerationForRun(evt.runId); const isControlUiVisible = evt.controlUiVisible ?? runContext?.isControlUiVisible ?? true; const projectSessionLifecycle = evt.projectSessionLifecycle ?? runContext?.projectSessionLifecycle ?? true; const projectSessionMessages = evt.projectSessionMessages ?? runContext?.projectSessionMessages ?? true; const isHeartbeat = runContext?.isHeartbeat; const restartRecoverySessionKey = RESTART_RECOVERY_LIFECYCLE_PHASES.has(lifecyclePhase ?? "") ? eventSessionKey ?? sessionKey : void 0; const restartRecoveryAgentId = evt.agentId ?? sessionAgentId; const clientRunId = chatLink?.clientRunId ?? evt.runId; if (chatRunState.runs.get(clientRunId)?.bufferIsCurrent?.() === false) { chatRunState.clearRun(clientRunId); agentRunSeq.delete(evt.runId); } const eventRunId = chatLink?.clientRunId ?? evt.runId; const eventForClients = chatLink ? { ...evt, runId: eventRunId } : evt; const isAborted = isChatAbortMarkerCurrent(chatRunState.runs.get(clientRunId)?.abortMarker, chatLink) || isChatAbortMarkerCurrent(chatRunState.runs.get(evt.runId)?.abortMarker, chatLink); const recordsEmbeddedProgress = !chatLink && isActiveEmbeddedRunId(evt.runId); const recordsInFlightProgress = Boolean(chatLink) && isControlUiVisible || recordsEmbeddedProgress; const restartRecoveryState = restartRecoverySessionKey ? resolveRestartRecoveryLifecycleState(restartRecoverySessionKey, restartRecoveryAgentId, evt) : void 0; if (lifecyclePhase !== null && (Boolean(evt.lifecycleGeneration && activeLifecycleGeneration && evt.lifecycleGeneration !== activeLifecycleGeneration) || restartRecoveryState?.suppress === true)) { clearPendingTerminalLifecycleError(evt.runId, evt.lifecycleGeneration); if (lifecyclePhase === "end" || lifecyclePhase === "error") finalizeLifecycleEvent(evt, { suppressRestartRecoveryProjection: true, restartRecoveryState }); return; } if (lifecyclePhase !== null && lifecyclePhase !== "error") clearPendingTerminalLifecycleError(evt.runId); const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null; const agentPayload = sessionKey ? { ...eventForClients, sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, ...spawnedBy && { spawnedBy }, ...isHeartbeat !== void 0 && { isHeartbeat } } : { ...eventForClients, ...isHeartbeat !== void 0 && { isHeartbeat } }; const hasSessionMessageSubscribers = projectSessionMessages && sessionKey ? resolveSessionDeliveryKeys(sessionKey, sessionAgentId).some((deliverySessionKey) => sessionMessageSubscribers.get(deliverySessionKey).size > 0) : false; const last = agentRunSeq.get(evt.runId) ?? 0; const isToolEvent = evt.stream === "tool"; const isItemEvent = evt.stream === "item"; const toolVerbose = isToolEvent ? resolveToolVerboseLevel(evt.runId, sessionKey) : "off"; const suppressHeartbeatToolEvents = isToolEvent && shouldSuppressHeartbeatToolEvents(clientRunId, evt.runId); const channelToolPayload = isToolEvent && toolVerbose !== "full" ? (() => { const data = evt.data ? { ...evt.data } : {}; delete data.result; delete data.partialResult; return { ...agentPayload, data }; })() : agentPayload; if (last > 0 && evt.seq !== last + 1 && isControlUiVisible) { flushBufferedAgentDeltaIfNeeded(clientRunId); broadcast("agent", { runId: eventRunId, stream: "error", ts: Date.now(), sessionKey, ...spawnedBy && { spawnedBy }, ...isHeartbeat !== void 0 && { isHeartbeat }, data: { reason: "seq gap", expected: last + 1, received: evt.seq } }, { sessionKeys: sessionKey ? resolveSessionDeliveryKeys(sessionKey, sessionAgentId) : void 0, liveText: liveTextDelivery(clientRunId) }); } agentRunSeq.set(evt.runId, evt.seq); if (evt.stream === "assistant") updateRunToolErrorSummary?.({ runId: evt.runId, clientRunId, summary: void 0 }); if (evt.stream === "plan" && evt.data?.phase === "update") { const steps = normalizeAgentPlanSteps(evt.data.steps) ?? []; const explanation = typeof evt.data.explanation === "string" ? evt.data.explanation.trim() : ""; chatRunState.getOrCreate(clientRunId).planSnapshot = { steps, ...explanation ? { explanation } : {} }; } if (recordsInFlightProgress && !isAborted && !suppressHeartbeatToolEvents) chatRunState.recordProgressEvent(clientRunId, agentPayload, recordsEmbeddedProgress ? "summary" : "full"); if (evt.stream === "run_status") { const phase = readChatRunStartupPhase(evt.data?.phase); if (phase && chatLink && isControlUiVisible && sessionKey && !isAborted) { const payload = { runId: clientRunId, sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, ...spawnedBy && { spawnedBy }, seq: evt.seq, state: "status", phase }; sendLivePayload("chat", sessionKey, payload, { agentId: sessionAgentId, controlUiVisible: true, dropIfSlow: true }); } } if (isToolEvent) { const toolPhase = typeof evt.data?.phase === "string" ? evt.data.phase : ""; if (toolPhase === "start") updateRunToolErrorSummary?.({ runId: evt.runId, clientRunId, summary: void 0 }); else if (toolPhase === "result") updateRunToolErrorSummary?.({ runId: evt.runId, clientRunId, summary: readToolValidationErrorSummary(evt.data?.toolErrorSummary) }); if (toolPhase === "start" && (isControlUiVisible || hasSessionMessageSubscribers) && sessionKey && !isAborted && !suppressHeartbeatToolEvents) { flushBufferedChatDeltaIfNeeded(sessionKey, sessionAgentId, clientRunId, evt.runId, evt.seq, { controlUiVisible: isControlUiVisible }); flushBufferedAgentDeltaIfNeeded(clientRunId); } const runToolRecipients = toolEventRecipients.get(evt.runId); if (isControlUiVisible && !suppressHeartbeatToolEvents && runToolRecipients && runToolRecipients.size > 0) broadcastToConnIds("agent", sessionKey ? { ...agentPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) } : agentPayload, runToolRecipients, { sessionKeys: sessionKey ? resolveSessionDeliveryKeys(sessionKey, sessionAgentId) : void 0, liveText: liveTextDelivery(clientRunId) }); if (!isControlUiVisible && sessionKey && hasSessionMessageSubscribers && !suppressHeartbeatToolEvents) sendAgentPayload(sessionKey, { ...agentPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) }, { agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true }); if (isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) { const sessionSubscribers = excludeConnIds(sessionEventSubscribers.getAll(), runToolRecipients); if (sessionSubscribers.size > 0) broadcastToConnIds("session.tool", { ...agentPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) }, sessionSubscribers, { dropIfSlow: true, liveText: liveTextDelivery(clientRunId) }); } } else { if ((isItemEvent && typeof evt.data?.phase === "string" ? evt.data.phase : "") === "start" && (isControlUiVisible || hasSessionMessageSubscribers) && !isAborted) { if (sessionKey) flushBufferedChatDeltaIfNeeded(sessionKey, sessionAgentId, clientRunId, evt.runId, evt.seq, { controlUiVisible: isControlUiVisible }); flushBufferedAgentDeltaIfNeeded(clientRunId); } if (isControlUiVisible || sessionKey && hasSessionMessageSubscribers && (isItemEvent || shouldMirrorAgentEventToHiddenSessionMessages(evt) || !isAborted && evt.stream === "assistant" && shouldMirrorAssistantEventToHiddenSessionMessages(evt.data))) sendOrBufferAgentTextEvent(clientRunId, { sessionKey, agentId: sessionAgentId, controlUiVisible: isControlUiVisible, payload: !isControlUiVisible && sessionKey ? { ...agentPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) } : agentPayload, isCurrent }); } if ((isControlUiVisible || hasSessionMessageSubscribers) && sessionKey) { if (isControlUiVisible && isToolEvent && !suppressHeartbeatToolEvents && toolVerbose !== "off") sendNodeSessionPayloadForAgent(sessionKey, "agent", projectToolSearchCodeEventForChannelPayload({ ...channelToolPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) }), sessionAgentId); const assistantLiveChatInput = evt.stream === "assistant" ? resolveAssistantLiveChatInput(evt.data) : void 0; const suppressAssistant = shouldSuppressAssistantEventForLiveChat(evt.data); if (!isAborted && assistantLiveChatInput && (!suppressAssistant || assistantLiveChatInput.itemId)) emitChatDelta(sessionKey, sessionAgentId, clientRunId, evt.runId, evt.seq, suppressAssistant ? { ...assistantLiveChatInput, text: "", delta: "" } : assistantLiveChatInput, { controlUiVisible: isControlUiVisible, isCurrent }); } if (lifecyclePhase === "error") { const skipChatErrorFinal = isChatSendRunActive(evt.runId) && !chatLink; const definitiveTerminal = isDefinitiveRunLifecycle({ phase: lifecyclePhase, data: evt.data }); if (isAborted || definitiveTerminal || lifecycleErrorRetryGraceMs <= 0) finalizeLifecycleEvent(evt, { skipChatErrorFinal, restartRecoveryState }); else { if (evt.data.completionSource !== "reply-dispatch") { if (sessionKey) flushBufferedChatDeltaIfNeeded(sessionKey, sessionAgentId, clientRunId, evt.runId, evt.seq, { controlUiVisible: isControlUiVisible }); chatRunState.clearRun(clientRunId); } scheduleTerminalLifecycleError(evt, { skipChatErrorFinal, restartRecoveryState }); } return; } if (lifecyclePhase === "end") { finalizeLifecycleEvent(evt, { restartRecoveryState }); return; } if (projectSessionLifecycle && sessionKey && lifecyclePhase === "start") { persistGatewaySessionLifecycleEventForEvent({ sessionKey, agentId: sessionAgentId, event: { ...evt, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {} } }).catch((err) => { logError(`gateway: start session persistence failed session=${formatForLog(sessionKey)} run=${formatForLog(evt.runId)} error=${formatForLog(err)}`); }); const sessionEventConnIds = sessionEventSubscribers.getAll(); if (hasSessionChangeReceivers(sessionEventConnIds)) broadcastToConnIds("sessions.changed", { sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, phase: lifecyclePhase, runId: evt.runId, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}, ts: evt.ts, ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId, true, true) }, sessionEventConnIds, { dropIfSlow: true }); } }; return Object.assign(handleEvent, { dispose: () => { for (const pending of pendingTerminalLifecycleErrors.values()) clearTimeout(pending.timer); pendingTerminalLifecycleErrors.clear(); } }); } //#endregion export { createAgentEventHandler, createChatAbortMarker, createChatRunState, createSessionEventSubscriberRegistry, createSessionMessageSubscriberRegistry, resolveChatErrorKindFromError };