2026-01-14 09:11:16 +00:00
import crypto from "node:crypto" ;
import fs from "node:fs" ;
2026-03-03 16:28:38 -05:00
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js" ;
2026-01-14 09:11:16 +00:00
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 {
2026-03-11 19:08:55 +01:00
BILLING_ERROR_USER_MESSAGE ,
2026-01-14 09:11:16 +00:00
isCompactionFailureError ,
isContextOverflowError ,
2026-03-11 19:08:55 +01:00
isBillingErrorMessage ,
2026-01-20 10:06:47 +00:00
isLikelyContextOverflowError ,
2026-02-12 00:42:33 -03:00
isTransientHttpError ,
2026-01-16 03:00:40 +00:00
sanitizeUserFacingText ,
2026-01-14 09:11:16 +00:00
} from "../../agents/pi-embedded-helpers.js" ;
2026-02-01 10:03:47 +09:00
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js" ;
2026-01-14 09:11:16 +00:00
import {
2026-01-24 15:35:05 +13:00
resolveGroupSessionKey ,
2026-01-14 09:11:16 +00:00
resolveSessionTranscriptPath ,
type SessionEntry ,
2026-01-15 23:06:42 +00:00
updateSessionStore ,
2026-01-14 09:11:16 +00:00
} from "../../config/sessions.js" ;
import { logVerbose } from "../../globals.js" ;
2026-01-19 00:34:16 +00:00
import { emitAgentEvent , registerAgentRunContext } from "../../infra/agent-events.js" ;
2026-01-14 09:11:16 +00:00
import { defaultRuntime } from "../../runtime.js" ;
2026-01-17 10:17:57 +00:00
import {
isMarkdownCapableMessageChannel ,
resolveMessageChannel ,
} from "../../utils/message-channel.js" ;
2026-03-06 14:14:00 +08:00
import { isInternalMessageChannel } from "../../utils/message-channel.js" ;
2026-01-14 09:11:16 +00:00
import { stripHeartbeatToken } from "../heartbeat.js" ;
2026-02-18 01:34:35 +00:00
import type { TemplateContext } from "../templating.js" ;
import type { VerboseLevel } from "../thinking.js" ;
2026-02-26 15:51:52 +05:30
import {
HEARTBEAT_TOKEN ,
isSilentReplyPrefixText ,
isSilentReplyText ,
SILENT_REPLY_TOKEN ,
} from "../tokens.js" ;
2026-02-18 01:34:35 +00:00
import type { GetReplyOptions , ReplyPayload } from "../types.js" ;
2026-02-17 00:10:26 +00:00
import {
2026-03-13 22:46:08 +00:00
buildEmbeddedRunExecutionParams ,
2026-02-18 19:02:25 +00:00
resolveModelFallbackOptions ,
2026-02-17 00:10:26 +00:00
} from "./agent-runner-utils.js" ;
2026-02-14 22:59:50 -05:00
import { type BlockReplyPipeline } from "./block-reply-pipeline.js" ;
2026-02-18 01:34:35 +00:00
import type { FollowupRun } from "./queue.js" ;
2026-02-14 22:59:50 -05:00
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js" ;
2026-03-07 10:09:10 +05:30
import { createReplyMediaPathNormalizer } from "./reply-media-paths.js" ;
2026-02-18 01:34:35 +00:00
import type { TypingSignaler } from "./typing-mode.js" ;
2026-01-14 09:11:16 +00:00
2026-02-19 14:33:02 -08:00
export type RuntimeFallbackAttempt = {
provider : string ;
model : string ;
error : string ;
reason? : string ;
status? : number ;
code? : string ;
} ;
2026-01-14 09:11:16 +00:00
export type AgentRunLoopResult =
| {
kind : "success" ;
2026-02-19 14:33:02 -08:00
runId : string ;
2026-01-14 09:11:16 +00:00
runResult : Awaited < ReturnType < typeof runEmbeddedPiAgent > > ;
fallbackProvider? : string ;
fallbackModel? : string ;
2026-02-19 14:33:02 -08:00
fallbackAttempts : RuntimeFallbackAttempt [ ] ;
2026-01-14 09:11:16 +00:00
didLogHeartbeatStrip : boolean ;
autoCompactionCompleted : boolean ;
2026-01-15 20:55:52 -08:00
/** Payload keys sent directly (not via pipeline) during tool flush. */
directlySentBlockKeys? : Set < string > ;
2026-01-14 09:11:16 +00:00
}
| { 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" ;
2026-02-02 01:22:41 -08:00
flushOnParagraph? : boolean ;
2026-01-14 09:11:16 +00:00
} ;
resolvedBlockStreamingBreak : "text_end" | "message_end" ;
applyReplyToMode : ( payload : ReplyPayload ) = > ReplyPayload ;
shouldEmitToolResult : ( ) = > boolean ;
2026-01-17 05:33:27 +00:00
shouldEmitToolOutput : ( ) = > boolean ;
2026-01-14 09:11:16 +00:00
pendingToolTasks : Set < Promise < void > > ;
resetSessionAfterCompactionFailure : ( reason : string ) = > Promise < boolean > ;
2026-01-16 09:03:54 +00:00
resetSessionAfterRoleOrderingConflict : ( reason : string ) = > Promise < boolean > ;
2026-01-14 09:11:16 +00:00
isHeartbeat : boolean ;
sessionKey? : string ;
getActiveSessionEntry : ( ) = > SessionEntry | undefined ;
activeSessionStore? : Record < string , SessionEntry > ;
storePath? : string ;
resolvedVerboseLevel : VerboseLevel ;
} ) : Promise < AgentRunLoopResult > {
2026-02-12 00:42:33 -03:00
const TRANSIENT_HTTP_RETRY_DELAY_MS = 2 _500 ;
2026-01-14 09:11:16 +00:00
let didLogHeartbeatStrip = false ;
let autoCompactionCompleted = false ;
2026-01-15 20:55:52 -08:00
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set < string > ( ) ;
2026-01-14 09:11:16 +00:00
2026-01-23 22:51:37 +00:00
const runId = params . opts ? . runId ? ? crypto . randomUUID ( ) ;
2026-03-07 10:09:10 +05:30
const normalizeReplyMediaPaths = createReplyMediaPathNormalizer ( {
cfg : params.followupRun.run.config ,
sessionKey : params.sessionKey ,
workspaceDir : params.followupRun.run.workspaceDir ,
} ) ;
2026-02-19 18:47:07 +00:00
let didNotifyAgentRunStart = false ;
const notifyAgentRunStart = ( ) = > {
if ( didNotifyAgentRunStart ) {
return ;
}
didNotifyAgentRunStart = true ;
params . opts ? . onAgentRunStart ? . ( runId ) ;
} ;
2026-03-06 14:14:00 +08:00
const shouldSurfaceToControlUi = isInternalMessageChannel (
params . followupRun . run . messageProvider ? ?
params . sessionCtx . Surface ? ?
params . sessionCtx . Provider ,
) ;
2026-01-14 09:11:16 +00:00
if ( params . sessionKey ) {
registerAgentRunContext ( runId , {
sessionKey : params.sessionKey ,
verboseLevel : params.resolvedVerboseLevel ,
2026-01-26 16:03:59 -05:00
isHeartbeat : params.isHeartbeat ,
2026-03-06 14:14:00 +08:00
isControlUiVisible : shouldSurfaceToControlUi ,
2026-01-14 09:11:16 +00:00
} ) ;
}
let runResult : Awaited < ReturnType < typeof runEmbeddedPiAgent > > ;
let fallbackProvider = params . followupRun . run . provider ;
let fallbackModel = params . followupRun . run . model ;
2026-02-19 14:33:02 -08:00
let fallbackAttempts : RuntimeFallbackAttempt [ ] = [ ] ;
2026-01-14 09:11:16 +00:00
let didResetAfterCompactionFailure = false ;
2026-02-12 00:42:33 -03:00
let didRetryTransientHttpError = false ;
2026-03-03 16:28:38 -05:00
let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen (
params . getActiveSessionEntry ( ) ? . systemPromptReport ,
) ;
2026-01-14 09:11:16 +00:00
while ( true ) {
try {
2026-01-19 00:34:16 +00:00
const normalizeStreamingText = ( payload : ReplyPayload ) : { text? : string ; skip : boolean } = > {
2026-01-14 09:11:16 +00:00
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 } ;
2026-02-26 15:51:52 +05:30
}
if (
isSilentReplyPrefixText ( text , SILENT_REPLY_TOKEN ) ||
isSilentReplyPrefixText ( text , HEARTBEAT_TOKEN )
) {
return { skip : true } ;
2026-01-14 09:11:16 +00:00
}
2026-01-31 16:19:20 +09:00
if ( ! text ) {
2026-02-15 02:18:57 +00:00
// Allow media-only payloads (e.g. tool result screenshots) through.
if ( ( payload . mediaUrls ? . length ? ? 0 ) > 0 ) {
return { text : undefined , skip : false } ;
}
2026-01-31 16:19:20 +09:00
return { skip : true } ;
}
2026-02-09 19:52:24 -06:00
const sanitized = sanitizeUserFacingText ( text , {
errorContext : Boolean ( payload . isError ) ,
} ) ;
2026-01-31 16:19:20 +09:00
if ( ! sanitized . trim ( ) ) {
return { skip : true } ;
}
2026-01-16 03:00:40 +00:00
return { text : sanitized , skip : false } ;
2026-01-14 09:11:16 +00:00
} ;
2026-01-19 00:34:16 +00:00
const handlePartialForTyping = async ( payload : ReplyPayload ) : Promise < string | undefined > = > {
2026-02-21 18:05:23 +05:30
if ( isSilentReplyPrefixText ( payload . text , SILENT_REPLY_TOKEN ) ) {
return undefined ;
}
2026-01-14 09:11:16 +00:00
const { text , skip } = normalizeStreamingText ( payload ) ;
2026-01-31 16:19:20 +09:00
if ( skip || ! text ) {
return undefined ;
}
2026-01-14 09:11:16 +00:00
await params . typingSignals . signalTextDelta ( text ) ;
return text ;
} ;
const blockReplyPipeline = params . blockReplyPipeline ;
const onToolResult = params . opts ? . onToolResult ;
const fallbackResult = await runWithModelFallback ( {
2026-02-18 19:02:25 +00:00
. . . resolveModelFallbackOptions ( params . followupRun . run ) ,
2026-03-10 01:12:10 +03:00
runId ,
2026-03-05 20:02:36 -08:00
run : ( provider , model , runOptions ) = > {
feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).
Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name
Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"
Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.
Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc
Fixes #923
2026-01-14 23:05:08 -05:00
// Notify that model selection is complete (including after fallback).
// This allows responsePrefix template interpolation with the actual model.
params . opts ? . onModelSelected ? . ( {
provider ,
model ,
thinkLevel : params.followupRun.run.thinkLevel ,
} ) ;
2026-01-14 09:11:16 +00:00
if ( isCliProvider ( provider , params . followupRun . run . config ) ) {
const startedAt = Date . now ( ) ;
2026-02-19 18:47:07 +00:00
notifyAgentRunStart ( ) ;
2026-01-14 09:11:16 +00:00
emitAgentEvent ( {
runId ,
stream : "lifecycle" ,
data : {
phase : "start" ,
startedAt ,
} ,
} ) ;
2026-01-19 00:34:16 +00:00
const cliSessionId = getCliSessionId ( params . getActiveSessionEntry ( ) , provider ) ;
2026-02-02 02:06:14 -08:00
return ( async ( ) = > {
let lifecycleTerminalEmitted = false ;
try {
const result = await runCliAgent ( {
sessionId : params.followupRun.run.sessionId ,
sessionKey : params.sessionKey ,
2026-02-07 01:16:58 +07:00
agentId : params.followupRun.run.agentId ,
2026-02-02 02:06:14 -08:00
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 ,
2026-03-03 16:28:38 -05:00
bootstrapPromptWarningSignaturesSeen ,
bootstrapPromptWarningSignature :
bootstrapPromptWarningSignaturesSeen [
bootstrapPromptWarningSignaturesSeen . length - 1
] ,
2026-02-02 02:06:14 -08:00
images : params.opts?.images ,
} ) ;
2026-03-03 16:28:38 -05:00
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen (
result . meta ? . systemPromptReport ,
) ;
2026-02-02 02:06:14 -08:00
2026-01-25 20:11:57 +01:00
// CLI backends don't emit streaming assistant events, so we need to
// emit one with the final text so server-chat can populate its buffer
// and send the response to TUI/WebSocket clients.
const cliText = result . payloads ? . [ 0 ] ? . text ? . trim ( ) ;
if ( cliText ) {
emitAgentEvent ( {
runId ,
stream : "assistant" ,
data : { text : cliText } ,
} ) ;
}
2026-02-02 02:06:14 -08:00
2026-01-14 09:11:16 +00:00
emitAgentEvent ( {
runId ,
stream : "lifecycle" ,
data : {
phase : "end" ,
startedAt ,
endedAt : Date.now ( ) ,
} ,
} ) ;
2026-02-02 02:06:14 -08:00
lifecycleTerminalEmitted = true ;
2026-01-14 09:11:16 +00:00
return result ;
2026-02-02 02:06:14 -08:00
} catch ( err ) {
2026-01-14 09:11:16 +00:00
emitAgentEvent ( {
runId ,
stream : "lifecycle" ,
data : {
phase : "error" ,
startedAt ,
endedAt : Date.now ( ) ,
2026-02-02 02:06:14 -08:00
error : String ( err ) ,
2026-01-14 09:11:16 +00:00
} ,
} ) ;
2026-02-02 02:06:14 -08:00
lifecycleTerminalEmitted = true ;
2026-01-14 09:11:16 +00:00
throw err ;
2026-02-02 02:06:14 -08:00
} finally {
// Defensive backstop: never let a CLI run complete without a terminal
// lifecycle event, otherwise downstream consumers can hang.
if ( ! lifecycleTerminalEmitted ) {
emitAgentEvent ( {
runId ,
stream : "lifecycle" ,
data : {
phase : "error" ,
startedAt ,
endedAt : Date.now ( ) ,
error : "CLI run completed without lifecycle terminal event" ,
} ,
} ) ;
}
}
} ) ( ) ;
2026-01-14 09:11:16 +00:00
}
2026-03-13 22:46:08 +00:00
const { embeddedContext , senderContext , runBaseParams } = buildEmbeddedRunExecutionParams (
{
run : params.followupRun.run ,
sessionCtx : params.sessionCtx ,
hasRepliedRef : params.opts?.hasRepliedRef ,
provider ,
runId ,
allowTransientCooldownProbe : runOptions?.allowTransientCooldownProbe ,
model ,
} ,
) ;
2026-03-03 16:28:38 -05:00
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" ;
2026-01-14 09:11:16 +00:00
}
2026-03-03 16:28:38 -05:00
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 ;
2026-01-14 09:11:16 +00:00
}
2026-03-03 16:28:38 -05:00
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 ) = > {
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt . stream === "lifecycle" && typeof evt . data . phase === "string" ;
if ( evt . stream !== "lifecycle" || hasLifecyclePhase ) {
notifyAgentRunStart ( ) ;
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if ( evt . stream === "tool" ) {
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 } ) ;
2026-01-14 09:11:16 +00:00
}
2026-03-03 16:28:38 -05:00
}
2026-03-13 12:06:15 +08:00
// Track auto-compaction completion and notify UI layer
2026-03-03 16:28:38 -05:00
if ( evt . stream === "compaction" ) {
const phase = typeof evt . data . phase === "string" ? evt . data . phase : "" ;
2026-03-13 12:06:15 +08:00
if ( phase === "start" ) {
await params . opts ? . onCompactionStart ? . ( ) ;
}
2026-03-03 16:28:38 -05:00
if ( phase === "end" ) {
autoCompactionCompleted = true ;
2026-03-13 12:06:15 +08:00
await params . opts ? . onCompactionEnd ? . ( ) ;
2026-03-03 16:28:38 -05:00
}
}
} ,
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply : params.opts?.onBlockReply
? createBlockReplyDeliveryHandler ( {
onBlockReply : params.opts.onBlockReply ,
currentMessageId :
params . sessionCtx . MessageSidFull ? ? params . sessionCtx . MessageSid ,
normalizeStreamingText ,
applyReplyToMode : params.applyReplyToMode ,
2026-03-07 10:09:10 +05:30
normalizeMediaPaths : normalizeReplyMediaPaths ,
2026-03-03 16:28:38 -05:00
typingSignals : params.typingSignals ,
blockStreamingEnabled : params.blockStreamingEnabled ,
blockReplyPipeline ,
directlySentBlockKeys ,
} )
2026-01-14 09:11:16 +00:00
: undefined ,
2026-03-03 16:28:38 -05:00
onBlockReplyFlush :
params . blockStreamingEnabled && blockReplyPipeline
? async ( ) = > {
await blockReplyPipeline . flush ( { force : true } ) ;
}
: undefined ,
shouldEmitToolResult : params.shouldEmitToolResult ,
shouldEmitToolOutput : params.shouldEmitToolOutput ,
bootstrapPromptWarningSignaturesSeen ,
bootstrapPromptWarningSignature :
bootstrapPromptWarningSignaturesSeen [
bootstrapPromptWarningSignaturesSeen . length - 1
] ,
onToolResult : onToolResult
? ( ( ) = > {
// Serialize tool result delivery to preserve message ordering.
// Without this, concurrent tool callbacks race through typing signals
// and message sends, causing out-of-order delivery to the user.
// See: https://github.com/openclaw/openclaw/issues/11044
let toolResultChain : Promise < void > = Promise . resolve ( ) ;
return ( payload : ReplyPayload ) = > {
toolResultChain = toolResultChain
. then ( async ( ) = > {
const { text , skip } = normalizeStreamingText ( payload ) ;
if ( skip ) {
return ;
}
await params . typingSignals . signalTextDelta ( text ) ;
await onToolResult ( {
2026-03-09 23:04:35 -04:00
. . . payload ,
2026-03-03 16:28:38 -05:00
text ,
} ) ;
} )
. catch ( ( err ) = > {
// Keep chain healthy after an error so later tool results still deliver.
logVerbose ( ` tool result delivery failed: ${ String ( err ) } ` ) ;
2026-02-20 01:23:23 +00:00
} ) ;
2026-03-03 16:28:38 -05:00
const task = toolResultChain . finally ( ( ) = > {
params . pendingToolTasks . delete ( task ) ;
2026-02-20 01:23:23 +00:00
} ) ;
2026-03-03 16:28:38 -05:00
params . pendingToolTasks . add ( task ) ;
} ;
} ) ( )
: undefined ,
} ) ;
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen (
result . meta ? . systemPromptReport ,
) ;
return result ;
} ) ( ) ;
2026-01-14 09:11:16 +00:00
} ,
} ) ;
runResult = fallbackResult . result ;
fallbackProvider = fallbackResult . provider ;
fallbackModel = fallbackResult . model ;
2026-02-19 14:33:02 -08:00
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 ,
} ) )
: [ ] ;
2026-01-14 09:11:16 +00:00
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult . meta ? . error ;
if (
embeddedError &&
isContextOverflowError ( embeddedError . message ) &&
! didResetAfterCompactionFailure &&
( await params . resetSessionAfterCompactionFailure ( embeddedError . message ) )
) {
didResetAfterCompactionFailure = true ;
2026-01-18 18:16:20 +00:00
return {
kind : "final" ,
payload : {
2026-02-22 20:13:43 -06:00
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." ,
2026-01-18 18:16:20 +00:00
} ,
} ;
2026-01-14 09:11:16 +00:00
}
2026-01-16 09:03:54 +00:00
if ( embeddedError ? . kind === "role_ordering" ) {
2026-01-19 00:34:16 +00:00
const didReset = await params . resetSessionAfterRoleOrderingConflict ( embeddedError . message ) ;
2026-01-16 09:03:54 +00:00
if ( didReset ) {
return {
kind : "final" ,
payload : {
text : "⚠️ Message ordering conflict. I've reset the conversation - please try again." ,
} ,
} ;
}
}
2026-01-14 09:11:16 +00:00
break ;
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err ) ;
2026-03-11 19:08:55 +01:00
const isBilling = isBillingErrorMessage ( message ) ;
const isContextOverflow = ! isBilling && isLikelyContextOverflowError ( message ) ;
const isCompactionFailure = ! isBilling && isCompactionFailureError ( message ) ;
2026-01-19 00:34:16 +00:00
const isSessionCorruption = /function call turn comes immediately after/i . test ( message ) ;
const isRoleOrderingError = /incorrect role information|roles must alternate/i . test ( message ) ;
2026-02-12 00:42:33 -03:00
const isTransientHttp = isTransientHttpError ( message ) ;
2026-01-14 09:11:16 +00:00
if (
isCompactionFailure &&
! didResetAfterCompactionFailure &&
( await params . resetSessionAfterCompactionFailure ( message ) )
) {
didResetAfterCompactionFailure = true ;
2026-01-18 18:16:20 +00:00
return {
kind : "final" ,
payload : {
2026-02-22 20:13:43 -06:00
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." ,
2026-01-18 18:16:20 +00:00
} ,
} ;
2026-01-14 09:11:16 +00:00
}
2026-01-16 09:03:54 +00:00
if ( isRoleOrderingError ) {
2026-01-19 00:34:16 +00:00
const didReset = await params . resetSessionAfterRoleOrderingConflict ( message ) ;
2026-01-16 09:03:54 +00:00
if ( didReset ) {
return {
kind : "final" ,
payload : {
text : "⚠️ Message ordering conflict. I've reset the conversation - please try again." ,
} ,
} ;
}
}
2026-01-14 09:11:16 +00:00
// Auto-recover from Gemini session corruption by resetting the session
if (
isSessionCorruption &&
params . sessionKey &&
params . activeSessionStore &&
params . storePath
) {
2026-01-15 23:06:42 +00:00
const sessionKey = params . sessionKey ;
2026-01-14 09:11:16 +00:00
const corruptedSessionId = params . getActiveSessionEntry ( ) ? . sessionId ;
defaultRuntime . error (
` Session history corrupted (Gemini function call ordering). Resetting session: ${ params . sessionKey } ` ,
) ;
try {
// Delete transcript file if it exists
if ( corruptedSessionId ) {
2026-01-19 00:34:16 +00:00
const transcriptPath = resolveSessionTranscriptPath ( corruptedSessionId ) ;
2026-01-14 09:11:16 +00:00
try {
fs . unlinkSync ( transcriptPath ) ;
} catch {
// Ignore if file doesn't exist
}
}
2026-01-15 23:09:47 +00:00
// Keep the in-memory snapshot consistent with the on-disk store reset.
delete params . activeSessionStore [ sessionKey ] ;
2026-01-15 23:06:42 +00:00
// Remove session entry from store using a fresh, locked snapshot.
await updateSessionStore ( params . storePath , ( store ) = > {
delete store [ sessionKey ] ;
} ) ;
2026-01-14 09:11:16 +00:00
} 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!" ,
} ,
} ;
}
2026-02-12 00:42:33 -03:00
if ( isTransientHttp && ! didRetryTransientHttpError ) {
didRetryTransientHttpError = true ;
// Retry the full runWithModelFallback() cycle — transient errors
// (502/521/etc.) typically affect the whole provider, so falling
// back to an alternate model first would not help. Instead we wait
// and retry the complete primary→fallback chain.
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 ;
}
2026-01-14 09:11:16 +00:00
defaultRuntime . error ( ` Embedded agent failed before reply: ${ message } ` ) ;
2026-02-12 00:42:33 -03:00
const safeMessage = isTransientHttp
? sanitizeUserFacingText ( message , { errorContext : true } )
: message ;
const trimmedMessage = safeMessage . replace ( /\.\s*$/ , "" ) ;
2026-03-11 19:08:55 +01:00
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 ` ;
2026-01-24 02:54:22 +00:00
2026-01-14 09:11:16 +00:00
return {
kind : "final" ,
payload : {
2026-01-24 02:54:22 +00:00
text : fallbackText ,
2026-01-14 09:11:16 +00:00
} ,
} ;
}
}
2026-02-25 23:53:43 +00:00
// If the run completed but with an embedded context overflow error that
// wasn't recovered from (e.g. compaction reset already attempted), surface
// the error to the user instead of silently returning an empty response.
// See #26905: Slack DM sessions silently swallowed messages when context
// overflow errors were returned as embedded error payloads.
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." ,
} ,
} ;
}
2026-01-14 09:11:16 +00:00
return {
kind : "success" ,
2026-02-19 14:33:02 -08:00
runId ,
2026-01-14 09:11:16 +00:00
runResult ,
fallbackProvider ,
fallbackModel ,
2026-02-19 14:33:02 -08:00
fallbackAttempts ,
2026-01-14 09:11:16 +00:00
didLogHeartbeatStrip ,
autoCompactionCompleted ,
2026-01-19 00:34:16 +00:00
directlySentBlockKeys : directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined ,
2026-01-14 09:11:16 +00:00
} ;
}