| import crypto from "node:crypto"; |
| import fs from "node:fs"; |
| import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; |
| import { runCliAgent } from "../../agents/cli-runner.js"; |
| import { getCliSessionId } from "../../agents/cli-session.js"; |
| import { runWithModelFallback } from "../../agents/model-fallback.js"; |
| import { isCliProvider } from "../../agents/model-selection.js"; |
| import { |
| BILLING_ERROR_USER_MESSAGE, |
| isCompactionFailureError, |
| isContextOverflowError, |
| isBillingErrorMessage, |
| isLikelyContextOverflowError, |
| isTransientHttpError, |
| sanitizeUserFacingText, |
| } from "../../agents/pi-embedded-helpers.js"; |
| import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; |
| import { |
| resolveGroupSessionKey, |
| resolveSessionTranscriptPath, |
| type SessionEntry, |
| updateSessionStore, |
| } from "../../config/sessions.js"; |
| import { logVerbose } from "../../globals.js"; |
| import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { |
| isMarkdownCapableMessageChannel, |
| resolveMessageChannel, |
| } from "../../utils/message-channel.js"; |
| import { isInternalMessageChannel } from "../../utils/message-channel.js"; |
| import { stripHeartbeatToken } from "../heartbeat.js"; |
| import type { TemplateContext } from "../templating.js"; |
| import type { VerboseLevel } from "../thinking.js"; |
| import { |
| HEARTBEAT_TOKEN, |
| isSilentReplyPrefixText, |
| isSilentReplyText, |
| SILENT_REPLY_TOKEN, |
| } from "../tokens.js"; |
| import type { GetReplyOptions, ReplyPayload } from "../types.js"; |
| import { |
| buildEmbeddedRunExecutionParams, |
| resolveModelFallbackOptions, |
| } from "./agent-runner-utils.js"; |
| import { type BlockReplyPipeline } from "./block-reply-pipeline.js"; |
| import type { FollowupRun } from "./queue.js"; |
| import { createBlockReplyDeliveryHandler } from "./reply-delivery.js"; |
| import { createReplyMediaPathNormalizer } from "./reply-media-paths.js"; |
| import type { TypingSignaler } from "./typing-mode.js"; |
|
|
| export type RuntimeFallbackAttempt = { |
| provider: string; |
| model: string; |
| error: string; |
| reason?: string; |
| status?: number; |
| code?: string; |
| }; |
|
|
| export type AgentRunLoopResult = |
| | { |
| kind: "success"; |
| runId: string; |
| runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>; |
| fallbackProvider?: string; |
| fallbackModel?: string; |
| fallbackAttempts: RuntimeFallbackAttempt[]; |
| didLogHeartbeatStrip: boolean; |
| autoCompactionCompleted: boolean; |
| sawToolActivity: boolean; |
| deliveredVisibleToolReply: boolean; |
| hasActiveBackgroundTask: boolean; |
| |
| directlySentBlockKeys?: Set<string>; |
| } |
| | { kind: "final"; payload: ReplyPayload }; |
|
|
| export async function runAgentTurnWithFallback(params: { |
| commandBody: string; |
| followupRun: FollowupRun; |
| sessionCtx: TemplateContext; |
| opts?: GetReplyOptions; |
| typingSignals: TypingSignaler; |
| blockReplyPipeline: BlockReplyPipeline | null; |
| blockStreamingEnabled: boolean; |
| blockReplyChunking?: { |
| minChars: number; |
| maxChars: number; |
| breakPreference: "paragraph" | "newline" | "sentence"; |
| flushOnParagraph?: boolean; |
| }; |
| resolvedBlockStreamingBreak: "text_end" | "message_end"; |
| applyReplyToMode: (payload: ReplyPayload) => ReplyPayload; |
| shouldEmitToolResult: () => boolean; |
| shouldEmitToolOutput: () => boolean; |
| pendingToolTasks: Set<Promise<void>>; |
| resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>; |
| resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>; |
| isHeartbeat: boolean; |
| sessionKey?: string; |
| getActiveSessionEntry: () => SessionEntry | undefined; |
| activeSessionStore?: Record<string, SessionEntry>; |
| storePath?: string; |
| resolvedVerboseLevel: VerboseLevel; |
| }): Promise<AgentRunLoopResult> { |
| const TRANSIENT_HTTP_RETRY_DELAY_MS = 2_500; |
| let didLogHeartbeatStrip = false; |
| let autoCompactionCompleted = false; |
| |
| const directlySentBlockKeys = new Set<string>(); |
|
|
| const runId = params.opts?.runId ?? crypto.randomUUID(); |
| const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ |
| cfg: params.followupRun.run.config, |
| sessionKey: params.sessionKey, |
| workspaceDir: params.followupRun.run.workspaceDir, |
| }); |
| let didNotifyAgentRunStart = false; |
| let sawToolActivity = false; |
| let deliveredVisibleToolReply = false; |
| let hasActiveBackgroundTask = false; |
| const notifyAgentRunStart = () => { |
| if (didNotifyAgentRunStart) { |
| return; |
| } |
| didNotifyAgentRunStart = true; |
| params.opts?.onAgentRunStart?.(runId); |
| }; |
| const shouldSurfaceToControlUi = isInternalMessageChannel( |
| params.followupRun.run.messageProvider ?? |
| params.sessionCtx.Surface ?? |
| params.sessionCtx.Provider, |
| ); |
| if (params.sessionKey) { |
| registerAgentRunContext(runId, { |
| sessionKey: params.sessionKey, |
| verboseLevel: params.resolvedVerboseLevel, |
| isHeartbeat: params.isHeartbeat, |
| isControlUiVisible: shouldSurfaceToControlUi, |
| }); |
| } |
| let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>; |
| let fallbackProvider = params.followupRun.run.provider; |
| let fallbackModel = params.followupRun.run.model; |
| let fallbackAttempts: RuntimeFallbackAttempt[] = []; |
| let didResetAfterCompactionFailure = false; |
| let didRetryTransientHttpError = false; |
| let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( |
| params.getActiveSessionEntry()?.systemPromptReport, |
| ); |
|
|
| while (true) { |
| try { |
| const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => { |
| let text = payload.text; |
| if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) { |
| const stripped = stripHeartbeatToken(text, { |
| mode: "message", |
| }); |
| if (stripped.didStrip && !didLogHeartbeatStrip) { |
| didLogHeartbeatStrip = true; |
| logVerbose("Stripped stray HEARTBEAT_OK token from reply"); |
| } |
| if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) { |
| return { skip: true }; |
| } |
| text = stripped.text; |
| } |
| if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { |
| return { skip: true }; |
| } |
| if ( |
| isSilentReplyPrefixText(text, SILENT_REPLY_TOKEN) || |
| isSilentReplyPrefixText(text, HEARTBEAT_TOKEN) |
| ) { |
| return { skip: true }; |
| } |
| if (!text) { |
| |
| if ((payload.mediaUrls?.length ?? 0) > 0) { |
| return { text: undefined, skip: false }; |
| } |
| return { skip: true }; |
| } |
| const sanitized = sanitizeUserFacingText(text, { |
| errorContext: Boolean(payload.isError), |
| }); |
| if (!sanitized.trim()) { |
| return { skip: true }; |
| } |
| return { text: sanitized, skip: false }; |
| }; |
| const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => { |
| if (isSilentReplyPrefixText(payload.text, SILENT_REPLY_TOKEN)) { |
| return undefined; |
| } |
| const { text, skip } = normalizeStreamingText(payload); |
| if (skip || !text) { |
| return undefined; |
| } |
| await params.typingSignals.signalTextDelta(text); |
| return text; |
| }; |
| const blockReplyPipeline = params.blockReplyPipeline; |
| const onToolResult = params.opts?.onToolResult; |
| const fallbackResult = await runWithModelFallback({ |
| ...resolveModelFallbackOptions(params.followupRun.run), |
| runId, |
| run: (provider, model, runOptions) => { |
| |
| |
| params.opts?.onModelSelected?.({ |
| provider, |
| model, |
| thinkLevel: params.followupRun.run.thinkLevel, |
| }); |
|
|
| if (isCliProvider(provider, params.followupRun.run.config)) { |
| const startedAt = Date.now(); |
| notifyAgentRunStart(); |
| emitAgentEvent({ |
| runId, |
| stream: "lifecycle", |
| data: { |
| phase: "start", |
| startedAt, |
| }, |
| }); |
| const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider); |
| return (async () => { |
| let lifecycleTerminalEmitted = false; |
| try { |
| const result = await runCliAgent({ |
| sessionId: params.followupRun.run.sessionId, |
| sessionKey: params.sessionKey, |
| agentId: params.followupRun.run.agentId, |
| sessionFile: params.followupRun.run.sessionFile, |
| workspaceDir: params.followupRun.run.workspaceDir, |
| config: params.followupRun.run.config, |
| prompt: params.commandBody, |
| provider, |
| model, |
| thinkLevel: params.followupRun.run.thinkLevel, |
| timeoutMs: params.followupRun.run.timeoutMs, |
| runId, |
| extraSystemPrompt: params.followupRun.run.extraSystemPrompt, |
| ownerNumbers: params.followupRun.run.ownerNumbers, |
| cliSessionId, |
| bootstrapPromptWarningSignaturesSeen, |
| bootstrapPromptWarningSignature: |
| bootstrapPromptWarningSignaturesSeen[ |
| bootstrapPromptWarningSignaturesSeen.length - 1 |
| ], |
| images: params.opts?.images, |
| }); |
| bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( |
| result.meta?.systemPromptReport, |
| ); |
|
|
| |
| |
| |
| const cliText = result.payloads?.[0]?.text?.trim(); |
| if (cliText) { |
| emitAgentEvent({ |
| runId, |
| stream: "assistant", |
| data: { text: cliText }, |
| }); |
| } |
|
|
| emitAgentEvent({ |
| runId, |
| stream: "lifecycle", |
| data: { |
| phase: "end", |
| startedAt, |
| endedAt: Date.now(), |
| }, |
| }); |
| lifecycleTerminalEmitted = true; |
|
|
| return result; |
| } catch (err) { |
| emitAgentEvent({ |
| runId, |
| stream: "lifecycle", |
| data: { |
| phase: "error", |
| startedAt, |
| endedAt: Date.now(), |
| error: String(err), |
| }, |
| }); |
| lifecycleTerminalEmitted = true; |
| throw err; |
| } finally { |
| |
| |
| if (!lifecycleTerminalEmitted) { |
| emitAgentEvent({ |
| runId, |
| stream: "lifecycle", |
| data: { |
| phase: "error", |
| startedAt, |
| endedAt: Date.now(), |
| error: "CLI run completed without lifecycle terminal event", |
| }, |
| }); |
| } |
| } |
| })(); |
| } |
| const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams( |
| { |
| run: params.followupRun.run, |
| sessionCtx: params.sessionCtx, |
| hasRepliedRef: params.opts?.hasRepliedRef, |
| provider, |
| runId, |
| allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, |
| model, |
| }, |
| ); |
| return (async () => { |
| const result = await runEmbeddedPiAgent({ |
| ...embeddedContext, |
| trigger: params.isHeartbeat ? "heartbeat" : "user", |
| groupId: resolveGroupSessionKey(params.sessionCtx)?.id, |
| groupChannel: |
| params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(), |
| groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined, |
| ...senderContext, |
| ...runBaseParams, |
| prompt: params.commandBody, |
| extraSystemPrompt: params.followupRun.run.extraSystemPrompt, |
| toolResultFormat: (() => { |
| const channel = resolveMessageChannel( |
| params.sessionCtx.Surface, |
| params.sessionCtx.Provider, |
| ); |
| if (!channel) { |
| return "markdown"; |
| } |
| return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; |
| })(), |
| suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings, |
| bootstrapContextMode: params.opts?.bootstrapContextMode, |
| bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default", |
| images: params.opts?.images, |
| abortSignal: params.opts?.abortSignal, |
| blockReplyBreak: params.resolvedBlockStreamingBreak, |
| blockReplyChunking: params.blockReplyChunking, |
| onPartialReply: async (payload) => { |
| const textForTyping = await handlePartialForTyping(payload); |
| if (!params.opts?.onPartialReply || textForTyping === undefined) { |
| return; |
| } |
| await params.opts.onPartialReply({ |
| text: textForTyping, |
| mediaUrls: payload.mediaUrls, |
| }); |
| }, |
| onAssistantMessageStart: async () => { |
| await params.typingSignals.signalMessageStart(); |
| await params.opts?.onAssistantMessageStart?.(); |
| }, |
| onReasoningStream: |
| params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream |
| ? async (payload) => { |
| await params.typingSignals.signalReasoningDelta(); |
| await params.opts?.onReasoningStream?.({ |
| text: payload.text, |
| mediaUrls: payload.mediaUrls, |
| }); |
| } |
| : undefined, |
| onReasoningEnd: params.opts?.onReasoningEnd, |
| onAgentEvent: async (evt) => { |
| |
| const hasLifecyclePhase = |
| evt.stream === "lifecycle" && typeof evt.data.phase === "string"; |
| if (evt.stream !== "lifecycle" || hasLifecyclePhase) { |
| notifyAgentRunStart(); |
| } |
| |
| |
| if (evt.stream === "tool") { |
| sawToolActivity = true; |
| const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; |
| const name = typeof evt.data.name === "string" ? evt.data.name : undefined; |
| if (phase === "start" || phase === "update") { |
| await params.typingSignals.signalToolStart(); |
| await params.opts?.onToolStart?.({ name, phase }); |
| } |
| } |
| |
| if (evt.stream === "compaction") { |
| const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; |
| if (phase === "start") { |
| await params.opts?.onCompactionStart?.(); |
| } |
| if (phase === "end") { |
| autoCompactionCompleted = true; |
| await params.opts?.onCompactionEnd?.(); |
| } |
| } |
| }, |
| |
| |
| |
| onBlockReply: params.opts?.onBlockReply |
| ? createBlockReplyDeliveryHandler({ |
| onBlockReply: params.opts.onBlockReply, |
| currentMessageId: |
| params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid, |
| normalizeStreamingText, |
| applyReplyToMode: params.applyReplyToMode, |
| normalizeMediaPaths: normalizeReplyMediaPaths, |
| typingSignals: params.typingSignals, |
| blockStreamingEnabled: params.blockStreamingEnabled, |
| blockReplyPipeline, |
| directlySentBlockKeys, |
| }) |
| : undefined, |
| onBlockReplyFlush: |
| params.blockStreamingEnabled && blockReplyPipeline |
| ? async () => { |
| await blockReplyPipeline.flush({ force: true }); |
| } |
| : undefined, |
| shouldEmitToolResult: params.shouldEmitToolResult, |
| shouldEmitToolOutput: params.shouldEmitToolOutput, |
| bootstrapPromptWarningSignaturesSeen, |
| bootstrapPromptWarningSignature: |
| bootstrapPromptWarningSignaturesSeen[ |
| bootstrapPromptWarningSignaturesSeen.length - 1 |
| ], |
| onToolResult: (() => { |
| |
| |
| |
| let toolResultChain: Promise<void> = Promise.resolve(); |
| return (payload: ReplyPayload) => { |
| toolResultChain = toolResultChain |
| .then(async () => { |
| const { text, skip } = normalizeStreamingText(payload); |
| const rawText = payload.text; |
| if ( |
| typeof rawText === "string" && |
| /\b(?:Command|Process) still running\b/i.test(rawText) |
| ) { |
| hasActiveBackgroundTask = true; |
| } |
| if (skip) { |
| return; |
| } |
| if ( |
| onToolResult && |
| (text?.trim() || payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0) |
| ) { |
| deliveredVisibleToolReply = true; |
| } |
| if ( |
| typeof text === "string" && |
| /\b(?:Command|Process) still running\b/i.test(text) |
| ) { |
| hasActiveBackgroundTask = true; |
| } |
| await params.typingSignals.signalTextDelta(text); |
| if (onToolResult) { |
| await onToolResult({ |
| ...payload, |
| text, |
| }); |
| } |
| }) |
| .catch((err) => { |
| |
| logVerbose(`tool result delivery failed: ${String(err)}`); |
| }); |
| const task = toolResultChain.finally(() => { |
| params.pendingToolTasks.delete(task); |
| }); |
| params.pendingToolTasks.add(task); |
| }; |
| })(), |
| }); |
| bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( |
| result.meta?.systemPromptReport, |
| ); |
| return result; |
| })(); |
| }, |
| }); |
| runResult = fallbackResult.result; |
| fallbackProvider = fallbackResult.provider; |
| fallbackModel = fallbackResult.model; |
| fallbackAttempts = Array.isArray(fallbackResult.attempts) |
| ? fallbackResult.attempts.map((attempt) => ({ |
| provider: String(attempt.provider ?? ""), |
| model: String(attempt.model ?? ""), |
| error: String(attempt.error ?? ""), |
| reason: attempt.reason ? String(attempt.reason) : undefined, |
| status: typeof attempt.status === "number" ? attempt.status : undefined, |
| code: attempt.code ? String(attempt.code) : undefined, |
| })) |
| : []; |
|
|
| |
| |
| const embeddedError = runResult.meta?.error; |
| if ( |
| embeddedError && |
| isContextOverflowError(embeddedError.message) && |
| !didResetAfterCompactionFailure && |
| (await params.resetSessionAfterCompactionFailure(embeddedError.message)) |
| ) { |
| didResetAfterCompactionFailure = true; |
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", |
| }, |
| }; |
| } |
| if (embeddedError?.kind === "role_ordering") { |
| const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message); |
| if (didReset) { |
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.", |
| }, |
| }; |
| } |
| } |
|
|
| break; |
| } catch (err) { |
| const message = err instanceof Error ? err.message : String(err); |
| const isBilling = isBillingErrorMessage(message); |
| const isContextOverflow = !isBilling && isLikelyContextOverflowError(message); |
| const isCompactionFailure = !isBilling && isCompactionFailureError(message); |
| const isSessionCorruption = /function call turn comes immediately after/i.test(message); |
| const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message); |
| const isTransientHttp = isTransientHttpError(message); |
|
|
| if ( |
| isCompactionFailure && |
| !didResetAfterCompactionFailure && |
| (await params.resetSessionAfterCompactionFailure(message)) |
| ) { |
| didResetAfterCompactionFailure = true; |
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", |
| }, |
| }; |
| } |
| if (isRoleOrderingError) { |
| const didReset = await params.resetSessionAfterRoleOrderingConflict(message); |
| if (didReset) { |
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.", |
| }, |
| }; |
| } |
| } |
|
|
| |
| if ( |
| isSessionCorruption && |
| params.sessionKey && |
| params.activeSessionStore && |
| params.storePath |
| ) { |
| const sessionKey = params.sessionKey; |
| const corruptedSessionId = params.getActiveSessionEntry()?.sessionId; |
| defaultRuntime.error( |
| `Session history corrupted (Gemini function call ordering). Resetting session: ${params.sessionKey}`, |
| ); |
|
|
| try { |
| |
| if (corruptedSessionId) { |
| const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId); |
| try { |
| fs.unlinkSync(transcriptPath); |
| } catch { |
| |
| } |
| } |
|
|
| |
| delete params.activeSessionStore[sessionKey]; |
|
|
| |
| await updateSessionStore(params.storePath, (store) => { |
| delete store[sessionKey]; |
| }); |
| } catch (cleanupErr) { |
| defaultRuntime.error( |
| `Failed to reset corrupted session ${params.sessionKey}: ${String(cleanupErr)}`, |
| ); |
| } |
|
|
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Session history was corrupted. I've reset the conversation - please try again!", |
| }, |
| }; |
| } |
|
|
| if (isTransientHttp && !didRetryTransientHttpError) { |
| didRetryTransientHttpError = true; |
| |
| |
| |
| |
| defaultRuntime.error( |
| `Transient HTTP provider error before reply (${message}). Retrying once in ${TRANSIENT_HTTP_RETRY_DELAY_MS}ms.`, |
| ); |
| await new Promise<void>((resolve) => { |
| setTimeout(resolve, TRANSIENT_HTTP_RETRY_DELAY_MS); |
| }); |
| continue; |
| } |
|
|
| defaultRuntime.error(`Embedded agent failed before reply: ${message}`); |
| const safeMessage = isTransientHttp |
| ? sanitizeUserFacingText(message, { errorContext: true }) |
| : message; |
| const trimmedMessage = safeMessage.replace(/\.\s*$/, ""); |
| const fallbackText = isBilling |
| ? BILLING_ERROR_USER_MESSAGE |
| : isContextOverflow |
| ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." |
| : isRoleOrderingError |
| ? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session." |
| : `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`; |
|
|
| return { |
| kind: "final", |
| payload: { |
| text: fallbackText, |
| }, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| const finalEmbeddedError = runResult?.meta?.error; |
| const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim()); |
| if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) { |
| return { |
| kind: "final", |
| payload: { |
| text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.", |
| }, |
| }; |
| } |
|
|
| return { |
| kind: "success", |
| runId, |
| runResult, |
| fallbackProvider, |
| fallbackModel, |
| fallbackAttempts, |
| didLogHeartbeatStrip, |
| autoCompactionCompleted, |
| sawToolActivity, |
| deliveredVisibleToolReply, |
| hasActiveBackgroundTask, |
| directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined, |
| }; |
| } |
|
|