UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

871 lines (870 loc) 36.7 kB
import { n as detectErrorKind } from "./errors-BXgSefBE.js"; import "./agent-scope-MrLta7Pq.js"; import { a as isSubagentSessionKey, n as isAcpSessionKey } from "./session-key-utils-Bx3apsJ3.js"; import { c as resolveDefaultAgentId } from "./agent-scope-config-CgCYpZfK.js"; import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js"; import { g as normalizeVerboseLevel } from "./thinking-OG7pb4lf.js"; import { t as setSafeTimeout } from "./timer-delay-T0mHtzex.js"; import { c as getAgentRunContext } from "./agent-events-C1B8VhOg.js"; import { f as updateSessionStoreEntry } from "./store-Qsgtu-0y.js"; import "./sessions-D6zZvoxX.js"; import { t as buildAgentRunTerminalOutcome } from "./agent-run-terminal-outcome-wSgVLvyR.js"; import { l as loadGatewaySessionRow, u as loadSessionEntry } from "./session-utils-CHNCuY8a.js"; import { d as stripHeartbeatToken } from "./heartbeat-CicGJ6Bb.js"; import { a as resolveToolSearchCodeDisplayTarget } from "./tool-display-common-TsxjqZSy.js"; import { i as shouldSuppressAssistantEventForLiveChat, n as projectLiveAssistantBufferedText, r as resolveMergedAssistantText, t as normalizeLiveAssistantEventText } from "./live-chat-projector-CnA2pXR3.js"; import { t as resolveHeartbeatVisibility } from "./heartbeat-visibility-BWEWr0c7.js"; import { t as formatForLog } from "./ws-log-DlzuStZb.js"; import { a as createToolEventRecipientRegistry, i as createSessionMessageSubscriberRegistry, n as createChatRunState, r as createSessionEventSubscriberRegistry, t as createChatRunRegistry } from "./server-chat-state-CcwJF39T.js"; import { performance } from "node:perf_hooks"; //#region src/gateway/session-lifecycle-state.ts function isFiniteTimestamp(value) { return typeof value === "number" && Number.isFinite(value) && value > 0; } function resolveLifecyclePhase(event) { const phase = typeof event.data?.phase === "string" ? event.data.phase : ""; return phase === "start" || phase === "end" || phase === "error" ? phase : null; } function mapAgentRunTerminalOutcomeToSessionStatus(outcome) { switch (outcome.reason) { case "completed": return "done"; case "hard_timeout": case "timed_out": return "timeout"; case "cancelled": case "aborted": return "killed"; case "blocked": case "failed": return "failed"; default: return outcome.reason; } } function resolveTerminalStatus(event) { return mapAgentRunTerminalOutcomeToSessionStatus(buildAgentRunTerminalOutcome({ status: resolveLifecyclePhase(event) === "error" ? "error" : event.data?.aborted === true ? "timeout" : "ok", error: event.data?.error, stopReason: event.data?.stopReason, livenessState: event.data?.livenessState, timeoutPhase: event.data?.timeoutPhase, providerStarted: event.data?.providerStarted, startedAt: event.data?.startedAt, endedAt: event.data?.endedAt ?? event.ts })); } function resolveLifecycleStartedAt(existingStartedAt, event) { if (isFiniteTimestamp(event.data?.startedAt)) return event.data.startedAt; if (isFiniteTimestamp(existingStartedAt)) return existingStartedAt; return isFiniteTimestamp(event.ts) ? event.ts : void 0; } function resolveLifecycleEndedAt(event) { if (isFiniteTimestamp(event.data?.endedAt)) return event.data.endedAt; return isFiniteTimestamp(event.ts) ? event.ts : void 0; } function resolveRuntimeMs(params) { const { startedAt, endedAt, existingRuntimeMs } = params; if (isFiniteTimestamp(startedAt) && isFiniteTimestamp(endedAt)) return Math.max(0, endedAt - startedAt); if (typeof existingRuntimeMs === "number" && Number.isFinite(existingRuntimeMs) && existingRuntimeMs >= 0) return existingRuntimeMs; } function deriveGatewaySessionLifecycleSnapshot(params) { const phase = resolveLifecyclePhase(params.event); if (!phase) return {}; const existing = params.session ?? void 0; if (phase === "start") { const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event); return { updatedAt: startedAt ?? existing?.updatedAt, status: "running", startedAt, endedAt: void 0, runtimeMs: void 0, abortedLastRun: false }; } const startedAt = resolveLifecycleStartedAt(existing?.startedAt, params.event); const endedAt = resolveLifecycleEndedAt(params.event); return { updatedAt: endedAt ?? existing?.updatedAt, status: resolveTerminalStatus(params.event), startedAt, endedAt, runtimeMs: resolveRuntimeMs({ startedAt, endedAt, existingRuntimeMs: existing?.runtimeMs }), abortedLastRun: resolveTerminalStatus(params.event) === "killed" }; } function derivePersistedSessionLifecyclePatch(params) { const snapshot = deriveGatewaySessionLifecycleSnapshot({ session: params.entry ?? void 0, event: params.event }); return { ...snapshot, updatedAt: typeof snapshot.updatedAt === "number" ? snapshot.updatedAt : void 0 }; } /** * A pre-`sessions.reset` run's lifecycle event must not mutate a session row * whose sessionId was rotated by the reset. True only when both the owning * run's sessionId and the current row's sessionId are known and differ. */ function isStaleLifecycleEventForSession(params) { return Boolean(params.owningSessionId && params.currentSessionId && params.owningSessionId !== params.currentSessionId); } async function persistGatewaySessionLifecycleEvent(params) { if (!resolveLifecyclePhase(params.event)) return; const sessionEntry = loadSessionEntry(params.sessionKey, { ...params.agentId ? { agentId: params.agentId } : {}, clone: false }); if (!sessionEntry.entry) return; const owningSessionId = typeof params.event.sessionId === "string" && params.event.sessionId ? params.event.sessionId : void 0; await updateSessionStoreEntry({ storePath: sessionEntry.storePath, sessionKey: sessionEntry.canonicalKey, skipMaintenance: true, takeCacheOwnership: true, update: async (entry) => { if (isStaleLifecycleEventForSession({ owningSessionId, currentSessionId: entry.sessionId })) return null; return derivePersistedSessionLifecyclePatch({ entry, event: params.event }); } }); } //#endregion //#region src/gateway/server-chat.ts 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 resolveHeartbeatAckMaxChars() { try { const cfg = getRuntimeConfig(); return Math.max(0, cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? 300); } catch { return 300; } } 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 { return !resolveHeartbeatVisibility({ cfg: getRuntimeConfig(), channel: "webchat" }).showOk; } catch { return true; } } function shouldSuppressHeartbeatToolEvents(runId, sourceRunId) { return Boolean(resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat); } function normalizeHeartbeatChatFinalText(params) { if (!shouldHideHeartbeatChatOutput(params.runId, params.sourceRunId)) return { suppress: false, text: params.text }; const stripped = stripHeartbeatToken(params.text, { mode: "heartbeat", maxAckChars: resolveHeartbeatAckMaxChars() }); if (!stripped.didStrip) return { suppress: false, text: params.text }; if (stripped.shouldSkip) return { suppress: true, text: "" }; return { suppress: false, text: stripped.text }; } /** * Keep this aligned with the agent.wait lifecycle-error grace so chat surfaces * do not finalize a run before fallback or retry reuses the same runId. */ const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15e3; const CHAT_ERROR_KINDS = new Set([ "refusal", "timeout", "rate_limit", "context_length", "unknown" ]); function buildChatErrorMessage(error) { const raw = error ? formatForLog(error).trim() : ""; if (!raw) return; return { role: "assistant", content: [{ type: "text", text: raw.startsWith("⚠️") || raw.startsWith("Error:") ? raw : `Error: ${raw}` }], timestamp: Date.now() }; } function readChatErrorKind(value) { return typeof value === "string" && CHAT_ERROR_KINDS.has(value) ? value : 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) { if (!params.text) return; const previous = params.previousBroadcastText; if (previous === void 0) return { deltaText: params.text }; if (!params.text.startsWith(previous)) return { deltaText: params.text, replace: true }; const deltaText = params.text.slice(previous.length); return deltaText ? { deltaText } : void 0; } function roundedChatSendTimingMs(value) { return Math.max(0, Math.round(value * 1e3) / 1e3); } function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSession, agentRunSeq, chatRunState, resolveSessionKeyForRun, clearAgentRunContext, toolEventRecipients, sessionEventSubscribers, sessionMessageSubscribers, loadGatewaySessionRowForSnapshot = loadGatewaySessionRow, lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS, isChatSendRunActive = () => false, clearTrackedActiveRun }) { const pendingTerminalLifecycleErrors = /* @__PURE__ */ new Map(); const agentTextThrottleKey = (clientRunId, stream) => `${clientRunId}:${stream}`; const agentTextThrottleKeys = (clientRunId) => [ clientRunId, agentTextThrottleKey(clientRunId, "assistant"), agentTextThrottleKey(clientRunId, "thinking") ]; const clearBufferedChatState = (clientRunId) => { chatRunState.clearRun(clientRunId); }; const clearPendingTerminalLifecycleError = (runId) => { const pending = pendingTerminalLifecycleErrors.get(runId); if (!pending) return; clearTimeout(pending.timer); pendingTerminalLifecycleErrors.delete(runId); }; const spawnedByCache = /* @__PURE__ */ new Map(); const resolveSpawnedBy = (sessionKey) => { if (spawnedByCache.has(sessionKey)) return spawnedByCache.get(sessionKey); if (!isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey)) return null; let result = null; try { result = loadGatewaySessionRow(sessionKey)?.spawnedBy ?? null; } catch {} spawnedByCache.set(sessionKey, result); return result; }; const buildSessionEventSnapshot = (sessionKey, evt, agentId) => { const row = loadGatewaySessionRowForSnapshot(sessionKey, agentId ? { agentId } : void 0); const omitUnscopedGlobalGoal = sessionKey === "global" && !agentId; const lifecyclePatch = evt && !isStaleLifecycleEventForSession({ owningSessionId: evt.sessionId, currentSessionId: row?.sessionId }) ? deriveGatewaySessionLifecycleSnapshot({ session: row ? { updatedAt: row.updatedAt ?? void 0, status: row.status, startedAt: row.startedAt, endedAt: row.endedAt, runtimeMs: row.runtimeMs, abortedLastRun: row.abortedLastRun } : void 0, event: evt }) : {}; const session = row ? { ...row, ...lifecyclePatch } : void 0; if (session && omitUnscopedGlobalGoal) delete session.goal; const snapshotSource = session ?? lifecyclePatch; return { ...session ? { session } : {}, updatedAt: snapshotSource.updatedAt, sessionId: row?.sessionId, kind: row?.kind, channel: row?.channel, subject: row?.subject, groupChannel: row?.groupChannel, space: row?.space, chatType: row?.chatType, origin: row?.origin, spawnedBy: row?.spawnedBy, spawnedWorkspaceDir: row?.spawnedWorkspaceDir, spawnedCwd: row?.spawnedCwd, forkedFromParent: row?.forkedFromParent, spawnDepth: row?.spawnDepth, subagentRole: row?.subagentRole, subagentControlScope: row?.subagentControlScope, label: row?.label, displayName: row?.displayName, deliveryContext: row?.deliveryContext, parentSessionKey: row?.parentSessionKey, childSessions: row?.childSessions, thinkingLevel: row?.thinkingLevel, fastMode: row?.fastMode, verboseLevel: row?.verboseLevel, traceLevel: row?.traceLevel, reasoningLevel: row?.reasoningLevel, elevatedLevel: row?.elevatedLevel, sendPolicy: row?.sendPolicy, systemSent: row?.systemSent, inputTokens: row?.inputTokens, outputTokens: row?.outputTokens, lastChannel: row?.lastChannel, lastTo: row?.lastTo, lastAccountId: row?.lastAccountId, lastThreadId: row?.lastThreadId, totalTokens: row?.totalTokens, totalTokensFresh: row?.totalTokensFresh, ...omitUnscopedGlobalGoal ? {} : { goal: row?.goal ?? null }, contextTokens: row?.contextTokens, estimatedCostUsd: row?.estimatedCostUsd, responseUsage: row?.responseUsage, modelProvider: row?.modelProvider, model: row?.model, status: snapshotSource.status, startedAt: snapshotSource.startedAt, endedAt: snapshotSource.endedAt, runtimeMs: snapshotSource.runtimeMs, abortedLastRun: snapshotSource.abortedLastRun }; }; const resolveSessionDeliveryKey = (sessionKey, agentId) => { if (sessionKey !== "global") return sessionKey; return `agent:${agentId ?? resolveDefaultAgentId(getRuntimeConfig())}:global`; }; const resolveNodeSessionDeliveryKeys = (sessionKey, agentId) => { if (sessionKey !== "global") return [sessionKey]; const defaultAgentId = resolveDefaultAgentId(getRuntimeConfig()); const scopedAgentId = agentId ?? defaultAgentId; const keys = [`agent:${scopedAgentId}:global`]; if (scopedAgentId === defaultAgentId) keys.push("global"); return keys; }; const sendNodeSessionPayloadForAgent = (sessionKey, event, payload, agentId) => { for (const deliverySessionKey of resolveNodeSessionDeliveryKeys(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) } : {} }, new Set([timing.connId]), { dropIfSlow: true }); }; const finalizeLifecycleEvent = (evt, opts) => { const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; if (lifecyclePhase !== "end" && lifecyclePhase !== "error") return; clearPendingTerminalLifecycleError(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId); const sessionAgentId = chatLink?.agentId ?? evt.agentId; const eventSessionKey = typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : void 0; const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true; const sessionKey = chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId); const clientRunId = chatLink?.clientRunId ?? evt.runId; const eventRunId = chatLink?.clientRunId ?? evt.runId; const isAborted = chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId); const deliverySessionKey = sessionKey ? resolveSessionDeliveryKey(sessionKey, sessionAgentId) : void 0; if (sessionKey && (isControlUiVisible || (deliverySessionKey ? sessionMessageSubscribers.get(deliverySessionKey).size > 0 : false))) if (!isAborted) { const evtStopReason = typeof evt.data?.stopReason === "string" ? evt.data.stopReason : void 0; const evtErrorKind = readChatErrorKind(evt.data?.errorKind) ?? detectErrorKind(evt.data?.error); if (chatLink) { const finished = chatRunState.registry.shift(evt.runId); if (!finished) { clearAgentRunContext(evt.runId); return; } if (!(opts?.skipChatErrorFinal && lifecyclePhase === "error")) emitChatFinal(finished.sessionKey, finished.clientRunId, evt.runId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error, evtStopReason, evtErrorKind, { agentId: finished.agentId, controlUiVisible: isControlUiVisible, firstAssistantTimingEntry: finished }); } else if (!(opts?.skipChatErrorFinal && lifecyclePhase === "error")) emitChatFinal(sessionKey, eventRunId, evt.runId, evt.seq, lifecyclePhase === "error" ? "error" : "done", evt.data?.error, evtStopReason, evtErrorKind, { agentId: sessionAgentId, controlUiVisible: isControlUiVisible }); } else { clearBufferedChatState(clientRunId); if (chatLink) chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); } toolEventRecipients.markFinal(evt.runId); clearBufferedChatState(clientRunId); clearAgentRunContext(evt.runId); agentRunSeq.delete(evt.runId); agentRunSeq.delete(clientRunId); if (sessionKey) { clearTrackedActiveRun?.({ runId: evt.runId, clientRunId, sessionKey }); persistGatewaySessionLifecycleEvent({ sessionKey, agentId: sessionAgentId, event: evt }).catch(() => void 0); const sessionEventConnIds = sessionEventSubscribers.getAll(); if (sessionEventConnIds.size > 0) broadcastToConnIds("sessions.changed", { sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, phase: lifecyclePhase, runId: evt.runId, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}, ts: evt.ts, ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId) }, sessionEventConnIds, { dropIfSlow: true }); } }; 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 emitChatDelta = (sessionKey, agentId, clientRunId, sourceRunId, seq, text, delta, opts) => { const cleaned = normalizeLiveAssistantEventText({ text, delta }); const mergedRawText = resolveMergedAssistantText({ previousText: chatRunState.rawBuffers.get(clientRunId) ?? "", nextText: cleaned.text, nextDelta: cleaned.delta }); if (!mergedRawText) return; const now = Date.now(); chatRunState.rawBuffers.set(clientRunId, mergedRawText); chatRunState.bufferUpdatedAt.set(clientRunId, now); const projected = projectLiveAssistantBufferedText(mergedRawText); const mergedText = projected.text; chatRunState.buffers.set(clientRunId, mergedText); if (projected.suppress) return; if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) return; if (now - (chatRunState.deltaSentAt.get(clientRunId) ?? 0) < 150) return; const broadcastDelta = resolveBroadcastDelta({ text: mergedText, previousBroadcastText: chatRunState.deltaLastBroadcastText.get(clientRunId) }); if (!broadcastDelta) return; chatRunState.deltaSentAt.set(clientRunId, now); chatRunState.deltaLastBroadcastLen.set(clientRunId, mergedText.length); chatRunState.deltaLastBroadcastText.set(clientRunId, mergedText); 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: mergedText }], timestamp: now } }; emitFirstAssistantChatSendTiming(chatRunState.registry.peek(sourceRunId)); sendChatPayload(sessionKey, payload, { agentId, controlUiVisible: opts?.controlUiVisible ?? true, dropIfSlow: true }); }; const resolveBufferedChatTextState = (clientRunId, sourceRunId, options) => { const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({ runId: clientRunId, sourceRunId, text: normalizeLiveAssistantEventText({ text: chatRunState.buffers.get(clientRunId) ?? "" }).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) => { const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: true }); const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(clientRunId, sourceRunId); if (!text || shouldSuppressSilent || shouldSuppressHeartbeatStreaming) return; const now = Date.now(); const delta = resolveBroadcastDelta({ text, previousBroadcastText: chatRunState.deltaLastBroadcastText.get(clientRunId) }); if (!delta) return; const spawnedBy = resolveSpawnedBy(sessionKey); const flushPayload = { runId: clientRunId, sessionKey, ...agentId ? { agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: "delta", deltaText: delta.deltaText, ...delta.replace ? { replace: true } : {}, message: { role: "assistant", content: [{ type: "text", text }], timestamp: now } }; emitFirstAssistantChatSendTiming(opts?.firstAssistantTimingEntry ?? chatRunState.registry.peek(sourceRunId)); sendChatPayload(sessionKey, flushPayload, { agentId, controlUiVisible: opts?.controlUiVisible ?? true, dropIfSlow: true }); chatRunState.deltaLastBroadcastLen.set(clientRunId, text.length); chatRunState.deltaLastBroadcastText.set(clientRunId, text); chatRunState.deltaSentAt.set(clientRunId, now); }; const sendChatPayload = (sessionKey, payload, opts) => { const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId); if (opts?.controlUiVisible ?? true) { broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow }); sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId); return; } const recipients = sessionMessageSubscribers.get(deliverySessionKey); if (recipients.size > 0) broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); }; const emitChatFinal = (sessionKey, clientRunId, sourceRunId, seq, jobState, error, stopReason, errorKind, opts) => { const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: false }); flushBufferedChatDeltaIfNeeded(sessionKey, opts?.agentId, clientRunId, sourceRunId, seq, opts); chatRunState.clearRun(clientRunId); const spawnedBy = resolveSpawnedBy(sessionKey); if (jobState === "done") { sendChatPayload(sessionKey, { runId: clientRunId, sessionKey, ...opts?.agentId ? { agentId: opts.agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: "final", ...stopReason && { stopReason }, message: text && !shouldSuppressSilent ? { role: "assistant", content: [{ type: "text", text }], timestamp: Date.now() } : void 0 }, opts); return; } sendChatPayload(sessionKey, { runId: clientRunId, sessionKey, ...opts?.agentId ? { agentId: opts.agentId } : {}, ...spawnedBy && { spawnedBy }, seq, state: "error", errorMessage: error ? formatForLog(error) : void 0, message: buildChatErrorMessage(error), ...errorKind && { errorKind } }, opts); }; const sendAgentPayload = (sessionKey, payload, opts) => { if (opts?.controlUiVisible ?? true) { broadcast("agent", payload); if (sessionKey) sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, opts?.agentId); return; } if (!sessionKey) return; const recipients = sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, opts?.agentId)); if (recipients.size > 0) broadcastToConnIds("agent", payload, recipients, { dropIfSlow: opts?.dropIfSlow }); }; const sendNodeAgentPayload = (sessionKey, payload, agentId) => { if (sessionKey) sendNodeSessionPayloadForAgent(sessionKey, "agent", payload, agentId); }; const flushBufferedAgentDeltaIfNeeded = (clientRunId, stream) => { const bufferedEntries = (stream ? [agentTextThrottleKey(clientRunId, stream)] : agentTextThrottleKeys(clientRunId)).flatMap((key) => { const buffered = chatRunState.bufferedAgentEvents.get(key); if (!buffered) return []; return [{ key, buffered }]; }); bufferedEntries.sort((a, b) => a.buffered.payload.seq - b.buffered.payload.seq); for (const { key, buffered } of bufferedEntries) { sendAgentPayload(buffered.sessionKey, buffered.payload, { agentId: buffered.agentId }); chatRunState.bufferedAgentEvents.delete(key); chatRunState.agentDeltaSentAt.set(key, Date.now()); } return bufferedEntries.length > 0; }; const resolveAgentTextThrottleStream = (evt) => { if (evt.stream === "assistant") return "assistant"; if (evt.stream === "thinking") return "thinking"; return null; }; const isAgentTextThrottleEvent = (evt) => resolveAgentTextThrottleStream(evt) !== null && typeof evt.data?.text === "string"; const shouldCoalesceAgentTextEvent = (evt) => isAgentTextThrottleEvent(evt) && typeof evt.data.delta === "string" && evt.data.delta.length > 0 && !(Array.isArray(evt.data.mediaUrls) && evt.data.mediaUrls.length > 0) && typeof evt.data.mediaUrl !== "string" && evt.data.replace !== true && (evt.stream !== "assistant" || !shouldSuppressAssistantEventForLiveChat(evt.data)); const shouldAdvanceAgentTextThrottle = (evt) => isAgentTextThrottleEvent(evt) && (typeof evt.data.delta === "string" || evt.data.replace === true); const buildBufferedAgentEvent = (sessionKey, agentId, payload) => sessionKey ? { sessionKey, agentId, payload } : { agentId, payload }; const mergeBufferedAgentPayload = (previous, next) => { if (previous.payload.stream !== next.payload.stream) return next; const previousDelta = previous.payload.data.delta; const nextDelta = next.payload.data.delta; if (typeof previousDelta !== "string" || typeof nextDelta !== "string") return next; return { ...next, payload: { ...next.payload, data: { ...next.payload.data, delta: `${previousDelta}${nextDelta}` } } }; }; const sendOrBufferAgentTextEvent = (clientRunId, sessionKey, agentId, payload) => { const stream = resolveAgentTextThrottleStream(payload); if (!stream) { sendAgentPayload(sessionKey, payload, { agentId }); return; } const now = Date.now(); const key = agentTextThrottleKey(clientRunId, stream); const last = chatRunState.agentDeltaSentAt.get(key); if (last !== void 0 && now - last < 150) { const nextBuffered = buildBufferedAgentEvent(sessionKey, agentId, payload); const buffered = chatRunState.bufferedAgentEvents.get(key); chatRunState.bufferedAgentEvents.set(key, buffered ? mergeBufferedAgentPayload(buffered, nextBuffered) : nextBuffered); return; } flushBufferedAgentDeltaIfNeeded(clientRunId); sendAgentPayload(sessionKey, payload, { agentId }); chatRunState.agentDeltaSentAt.set(key, now); }; const resolveToolVerboseLevel = (runId, sessionKey) => { const runContext = getAgentRunContext(runId); const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); if (!sessionKey) return runVerbose ?? "off"; try { const { cfg, entry } = loadSessionEntry(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"; } }; return (evt) => { const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; if (lifecyclePhase !== null && lifecyclePhase !== "error") clearPendingTerminalLifecycleError(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId); const sessionAgentId = chatLink?.agentId ?? evt.agentId; const eventSessionKey = typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : void 0; const runContext = getAgentRunContext(evt.runId); const isControlUiVisible = runContext?.isControlUiVisible ?? true; const isHeartbeat = runContext?.isHeartbeat; const sessionKey = chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId); const clientRunId = chatLink?.clientRunId ?? evt.runId; const eventRunId = chatLink?.clientRunId ?? evt.runId; const eventForClients = chatLink ? { ...evt, runId: eventRunId } : evt; const isAborted = chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(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 = sessionKey ? sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)).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 shouldCoalesceAgentEvent = shouldCoalesceAgentTextEvent(evt); 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 } }); } agentRunSeq.set(evt.runId, evt.seq); if (isToolEvent) { if ((typeof evt.data?.phase === "string" ? evt.data.phase : "") === "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); if (!isControlUiVisible && sessionKey && !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 }); } } 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) if (shouldCoalesceAgentEvent) sendOrBufferAgentTextEvent(clientRunId, sessionKey, sessionAgentId, agentPayload); else { flushBufferedAgentDeltaIfNeeded(clientRunId); sendAgentPayload(sessionKey, agentPayload, { agentId: sessionAgentId, controlUiVisible: isControlUiVisible }); const textThrottleStream = resolveAgentTextThrottleStream(evt); if (textThrottleStream && shouldAdvanceAgentTextThrottle(evt)) chatRunState.agentDeltaSentAt.set(agentTextThrottleKey(clientRunId, textThrottleStream), Date.now()); } } if ((isControlUiVisible || hasSessionMessageSubscribers) && sessionKey) { if (isControlUiVisible && isToolEvent && !suppressHeartbeatToolEvents && toolVerbose !== "off") sendNodeAgentPayload(sessionKey, projectToolSearchCodeEventForChannelPayload({ ...channelToolPayload, ...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId) }), sessionAgentId); if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string" && !shouldSuppressAssistantEventForLiveChat(evt.data)) emitChatDelta(sessionKey, sessionAgentId, clientRunId, evt.runId, evt.seq, evt.data.text, evt.data.delta, { controlUiVisible: isControlUiVisible }); } if (lifecyclePhase === "error") { clearBufferedChatState(clientRunId); const skipChatErrorFinal = isChatSendRunActive(evt.runId) && !chatLink; const isFallbackExhaustedFailure = evt.data?.fallbackExhaustedFailure === true; if (isAborted || isFallbackExhaustedFailure || lifecycleErrorRetryGraceMs <= 0) finalizeLifecycleEvent(evt, { skipChatErrorFinal }); else scheduleTerminalLifecycleError(evt, { skipChatErrorFinal }); return; } if (lifecyclePhase === "end") { finalizeLifecycleEvent(evt); return; } if (sessionKey && lifecyclePhase === "start") { persistGatewaySessionLifecycleEvent({ sessionKey, agentId: sessionAgentId, event: evt }).catch(() => void 0); const sessionEventConnIds = sessionEventSubscribers.getAll(); if (sessionEventConnIds.size > 0) broadcastToConnIds("sessions.changed", { sessionKey, ...sessionAgentId ? { agentId: sessionAgentId } : {}, phase: lifecyclePhase, runId: evt.runId, ...eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}, ts: evt.ts, ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId) }, sessionEventConnIds, { dropIfSlow: true }); } }; } //#endregion export { createAgentEventHandler, createChatRunRegistry, createChatRunState, createSessionEventSubscriberRegistry, createSessionMessageSubscriberRegistry, createToolEventRecipientRegistry };