2026-01-14 01:08:15 +00:00
import fs from "node:fs/promises" ;
import type { ThinkLevel } from "../../auto-reply/thinking.js" ;
2026-02-15 12:05:29 -05:00
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js" ;
2026-01-14 01:08:15 +00:00
import { enqueueCommandInLane } from "../../process/command-queue.js" ;
2026-01-17 10:17:57 +00:00
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js" ;
2026-01-30 03:15:10 +01:00
import { resolveOpenClawAgentDir } from "../agent-paths.js" ;
2026-01-14 01:08:15 +00:00
import {
2026-01-22 10:04:56 +01:00
isProfileInCooldown ,
2026-01-14 01:08:15 +00:00
markAuthProfileFailure ,
markAuthProfileGood ,
markAuthProfileUsed ,
} from "../auth-profiles.js" ;
import {
CONTEXT_WINDOW_HARD_MIN_TOKENS ,
CONTEXT_WINDOW_WARN_BELOW_TOKENS ,
evaluateContextWindowGuard ,
resolveContextWindowInfo ,
} from "../context-window-guard.js" ;
2026-01-14 14:31:43 +00:00
import { DEFAULT_CONTEXT_TOKENS , DEFAULT_MODEL , DEFAULT_PROVIDER } from "../defaults.js" ;
2026-01-14 01:08:15 +00:00
import { FailoverError , resolveFailoverStatus } from "../failover-error.js" ;
import {
ensureAuthProfileStore ,
getApiKeyForModel ,
resolveAuthProfileOrder ,
2026-01-20 07:53:25 +00:00
type ResolvedProviderAuth ,
2026-01-14 01:08:15 +00:00
} from "../model-auth.js" ;
2026-01-21 06:00:16 +00:00
import { normalizeProviderId } from "../model-selection.js" ;
2026-01-30 03:15:10 +01:00
import { ensureOpenClawModelsJson } from "../models-config.js" ;
2026-01-14 01:08:15 +00:00
import {
2026-02-13 02:23:27 +08:00
formatBillingErrorMessage ,
2026-01-14 01:08:15 +00:00
classifyFailoverReason ,
formatAssistantErrorText ,
isAuthAssistantError ,
2026-02-05 17:58:43 -04:00
isBillingAssistantError ,
2026-01-14 01:08:15 +00:00
isCompactionFailureError ,
2026-02-13 00:53:13 +01:00
isLikelyContextOverflowError ,
2026-01-14 01:08:15 +00:00
isFailoverAssistantError ,
isFailoverErrorMessage ,
2026-01-27 15:59:11 -06:00
parseImageSizeError ,
2026-01-18 15:19:25 +00:00
parseImageDimensionError ,
2026-01-14 01:08:15 +00:00
isRateLimitAssistantError ,
isTimeoutErrorMessage ,
pickFallbackThinkingLevel ,
2026-01-24 06:14:17 +00:00
type FailoverReason ,
2026-01-14 01:08:15 +00:00
} from "../pi-embedded-helpers.js" ;
2026-02-13 00:53:13 +01:00
import { derivePromptTokens , normalizeUsage , type UsageLike } from "../usage.js" ;
2026-02-07 01:16:58 +07:00
import { redactRunIdentifier , resolveRunWorkspaceDir } from "../workspace-run.js" ;
2026-01-24 19:09:24 -03:00
import { compactEmbeddedPiSessionDirect } from "./compact.js" ;
2026-01-14 01:08:15 +00:00
import { resolveGlobalLane , resolveSessionLane } from "./lanes.js" ;
import { log } from "./logger.js" ;
import { resolveModel } from "./model.js" ;
import { runEmbeddedAttempt } from "./run/attempt.js" ;
2026-02-18 01:34:35 +00:00
import type { RunEmbeddedPiAgentParams } from "./run/params.js" ;
2026-01-14 01:08:15 +00:00
import { buildEmbeddedRunPayloads } from "./run/payloads.js" ;
2026-02-07 17:40:51 -08:00
import {
truncateOversizedToolResultsInSession ,
sessionLikelyHasOversizedToolResults ,
} from "./tool-result-truncation.js" ;
2026-02-18 01:34:35 +00:00
import type { EmbeddedPiAgentMeta , EmbeddedPiRunResult } from "./types.js" ;
2026-01-14 01:08:15 +00:00
import { describeUnknownError } from "./utils.js" ;
2026-01-20 07:53:25 +00:00
type ApiKeyInfo = ResolvedProviderAuth ;
2026-01-14 01:08:15 +00:00
2026-01-21 07:28:11 +00:00
// Avoid Anthropic's refusal test token poisoning session transcripts.
const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL" ;
const ANTHROPIC_MAGIC_STRING_REPLACEMENT = "ANTHROPIC MAGIC STRING TRIGGER REFUSAL (redacted)" ;
function scrubAnthropicRefusalMagic ( prompt : string ) : string {
2026-01-31 16:19:20 +09:00
if ( ! prompt . includes ( ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL ) ) {
return prompt ;
}
2026-01-21 07:28:11 +00:00
return prompt . replaceAll (
ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL ,
ANTHROPIC_MAGIC_STRING_REPLACEMENT ,
) ;
}
2026-02-07 20:02:32 -08:00
type UsageAccumulator = {
input : number ;
output : number ;
cacheRead : number ;
cacheWrite : number ;
total : number ;
2026-02-12 23:01:36 +09:00
/** Cache fields from the most recent API call (not accumulated). */
lastCacheRead : number ;
lastCacheWrite : number ;
lastInput : number ;
2026-02-07 20:02:32 -08:00
} ;
const createUsageAccumulator = ( ) : UsageAccumulator = > ( {
input : 0 ,
output : 0 ,
cacheRead : 0 ,
cacheWrite : 0 ,
total : 0 ,
2026-02-12 23:01:36 +09:00
lastCacheRead : 0 ,
lastCacheWrite : 0 ,
lastInput : 0 ,
2026-02-07 20:02:32 -08:00
} ) ;
2026-02-13 19:54:22 -04:00
function createCompactionDiagId ( ) : string {
return ` ovf- ${ Date . now ( ) . toString ( 36 ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
}
2026-02-07 20:02:32 -08:00
const hasUsageValues = (
usage : ReturnType < typeof normalizeUsage > ,
) : usage is NonNullable < ReturnType < typeof normalizeUsage > > = >
! ! usage &&
[ usage . input , usage . output , usage . cacheRead , usage . cacheWrite , usage . total ] . some (
( value ) = > typeof value === "number" && Number . isFinite ( value ) && value > 0 ,
) ;
const mergeUsageIntoAccumulator = (
target : UsageAccumulator ,
usage : ReturnType < typeof normalizeUsage > ,
) = > {
if ( ! hasUsageValues ( usage ) ) {
return ;
}
target . input += usage . input ? ? 0 ;
target . output += usage . output ? ? 0 ;
target . cacheRead += usage . cacheRead ? ? 0 ;
target . cacheWrite += usage . cacheWrite ? ? 0 ;
target . total +=
usage . total ? ?
( usage . input ? ? 0 ) + ( usage . output ? ? 0 ) + ( usage . cacheRead ? ? 0 ) + ( usage . cacheWrite ? ? 0 ) ;
2026-02-12 23:01:36 +09:00
// Track the most recent API call's cache fields for accurate context-size reporting.
// Accumulated cache totals inflate context size when there are multiple tool-call round-trips,
// since each call reports cacheRead ≈ current_context_size.
target . lastCacheRead = usage . cacheRead ? ? 0 ;
target . lastCacheWrite = usage . cacheWrite ? ? 0 ;
target . lastInput = usage . input ? ? 0 ;
2026-02-07 20:02:32 -08:00
} ;
const toNormalizedUsage = ( usage : UsageAccumulator ) = > {
const hasUsage =
usage . input > 0 ||
usage . output > 0 ||
usage . cacheRead > 0 ||
usage . cacheWrite > 0 ||
usage . total > 0 ;
if ( ! hasUsage ) {
return undefined ;
}
2026-02-12 23:01:36 +09:00
// Use the LAST API call's cache fields for context-size calculation.
// The accumulated cacheRead/cacheWrite inflate context size because each tool-call
// round-trip reports cacheRead ≈ current_context_size, and summing N calls gives
// N × context_size which gets clamped to contextWindow (e.g. 200k).
// See: https://github.com/openclaw/openclaw/issues/13698
//
// We use lastInput/lastCacheRead/lastCacheWrite (from the most recent API call) for
// cache-related fields, but keep accumulated output (total generated text this turn).
const lastPromptTokens = usage . lastInput + usage . lastCacheRead + usage . lastCacheWrite ;
2026-02-07 20:02:32 -08:00
return {
2026-02-12 23:01:36 +09:00
input : usage.lastInput || undefined ,
2026-02-07 20:02:32 -08:00
output : usage.output || undefined ,
2026-02-12 23:01:36 +09:00
cacheRead : usage.lastCacheRead || undefined ,
cacheWrite : usage.lastCacheWrite || undefined ,
total : lastPromptTokens + usage . output || undefined ,
2026-02-07 20:02:32 -08:00
} ;
} ;
2026-02-19 10:56:00 +08:00
function resolveActiveErrorContext ( params : {
lastAssistant : { provider? : string ; model? : string } | undefined ;
provider : string ;
model : string ;
} ) : { provider : string ; model : string } {
return {
provider : params.lastAssistant?.provider ? ? params . provider ,
model : params.lastAssistant?.model ? ? params . model ,
} ;
}
2026-01-14 01:08:15 +00:00
export async function runEmbeddedPiAgent (
params : RunEmbeddedPiAgentParams ,
) : Promise < EmbeddedPiRunResult > {
2026-01-14 14:31:43 +00:00
const sessionLane = resolveSessionLane ( params . sessionKey ? . trim ( ) || params . sessionId ) ;
2026-01-14 01:08:15 +00:00
const globalLane = resolveGlobalLane ( params . lane ) ;
const enqueueGlobal =
2026-01-14 14:31:43 +00:00
params . enqueue ? ? ( ( task , opts ) = > enqueueCommandInLane ( globalLane , task , opts ) ) ;
2026-01-24 02:05:31 +00:00
const enqueueSession =
params . enqueue ? ? ( ( task , opts ) = > enqueueCommandInLane ( sessionLane , task , opts ) ) ;
2026-01-17 10:17:57 +00:00
const channelHint = params . messageChannel ? ? params . messageProvider ;
const resolvedToolResultFormat =
params . toolResultFormat ? ?
( channelHint
? isMarkdownCapableMessageChannel ( channelHint )
? "markdown"
: "plain"
: "markdown" ) ;
2026-01-24 00:04:53 +00:00
const isProbeSession = params . sessionId ? . startsWith ( "probe-" ) ? ? false ;
2026-01-14 01:08:15 +00:00
2026-01-24 02:05:31 +00:00
return enqueueSession ( ( ) = >
2026-01-14 01:08:15 +00:00
enqueueGlobal ( async ( ) = > {
const started = Date . now ( ) ;
2026-02-07 01:16:58 +07:00
const workspaceResolution = resolveRunWorkspaceDir ( {
workspaceDir : params.workspaceDir ,
sessionKey : params.sessionKey ,
agentId : params.agentId ,
config : params.config ,
} ) ;
const resolvedWorkspace = workspaceResolution . workspaceDir ;
const redactedSessionId = redactRunIdentifier ( params . sessionId ) ;
const redactedSessionKey = redactRunIdentifier ( params . sessionKey ) ;
const redactedWorkspace = redactRunIdentifier ( resolvedWorkspace ) ;
if ( workspaceResolution . usedFallback ) {
log . warn (
` [workspace-fallback] caller=runEmbeddedPiAgent reason= ${ workspaceResolution . fallbackReason } run= ${ params . runId } session= ${ redactedSessionId } sessionKey= ${ redactedSessionKey } agent= ${ workspaceResolution . agentId } workspace= ${ redactedWorkspace } ` ,
) ;
}
2026-01-14 01:08:15 +00:00
const prevCwd = process . cwd ( ) ;
2026-02-15 12:05:29 -05:00
let provider = ( params . provider ? ? DEFAULT_PROVIDER ) . trim ( ) || DEFAULT_PROVIDER ;
let modelId = ( params . model ? ? DEFAULT_MODEL ) . trim ( ) || DEFAULT_MODEL ;
2026-01-30 03:15:10 +01:00
const agentDir = params . agentDir ? ? resolveOpenClawAgentDir ( ) ;
2026-01-24 06:14:17 +00:00
const fallbackConfigured =
( params . config ? . agents ? . defaults ? . model ? . fallbacks ? . length ? ? 0 ) > 0 ;
2026-01-30 03:15:10 +01:00
await ensureOpenClawModelsJson ( params . config , agentDir ) ;
2026-01-14 01:08:15 +00:00
2026-02-17 03:28:10 +01:00
// Run before_model_resolve hooks early so plugins can override the
// provider/model before resolveModel().
//
// Legacy compatibility: before_agent_start is also checked for override
// fields if present. New hook takes precedence when both are set.
let modelResolveOverride : { providerOverride? : string ; modelOverride? : string } | undefined ;
2026-02-15 12:05:29 -05:00
const hookRunner = getGlobalHookRunner ( ) ;
2026-02-17 03:28:10 +01:00
const hookCtx = {
agentId : workspaceResolution.agentId ,
sessionKey : params.sessionKey ,
sessionId : params.sessionId ,
workspaceDir : resolvedWorkspace ,
messageProvider : params.messageProvider ? ? undefined ,
} ;
if ( hookRunner ? . hasHooks ( "before_model_resolve" ) ) {
try {
modelResolveOverride = await hookRunner . runBeforeModelResolve (
{ prompt : params.prompt } ,
hookCtx ,
) ;
} catch ( hookErr ) {
log . warn ( ` before_model_resolve hook failed: ${ String ( hookErr ) } ` ) ;
}
}
2026-02-15 12:05:29 -05:00
if ( hookRunner ? . hasHooks ( "before_agent_start" ) ) {
try {
2026-02-17 03:28:10 +01:00
const legacyResult = await hookRunner . runBeforeAgentStart (
2026-02-15 12:05:29 -05:00
{ prompt : params.prompt } ,
2026-02-17 03:28:10 +01:00
hookCtx ,
2026-02-15 12:05:29 -05:00
) ;
2026-02-17 03:28:10 +01:00
modelResolveOverride = {
providerOverride :
modelResolveOverride ? . providerOverride ? ? legacyResult ? . providerOverride ,
modelOverride : modelResolveOverride?.modelOverride ? ? legacyResult ? . modelOverride ,
} ;
2026-02-15 12:05:29 -05:00
} catch ( hookErr ) {
2026-02-17 03:28:10 +01:00
log . warn (
` before_agent_start hook (legacy model resolve path) failed: ${ String ( hookErr ) } ` ,
) ;
2026-02-15 12:05:29 -05:00
}
}
2026-02-17 03:28:10 +01:00
if ( modelResolveOverride ? . providerOverride ) {
provider = modelResolveOverride . providerOverride ;
log . info ( ` [hooks] provider overridden to ${ provider } ` ) ;
}
if ( modelResolveOverride ? . modelOverride ) {
modelId = modelResolveOverride . modelOverride ;
log . info ( ` [hooks] model overridden to ${ modelId } ` ) ;
}
2026-02-15 12:05:29 -05:00
2026-01-14 01:08:15 +00:00
const { model , error , authStorage , modelRegistry } = resolveModel (
provider ,
modelId ,
agentDir ,
params . config ,
) ;
if ( ! model ) {
2026-02-20 18:31:09 +08:00
throw new FailoverError ( error ? ? ` Unknown model: ${ provider } / ${ modelId } ` , {
reason : "model_not_found" ,
provider ,
model : modelId ,
} ) ;
2026-01-14 01:08:15 +00:00
}
const ctxInfo = resolveContextWindowInfo ( {
cfg : params.config ,
provider ,
modelId ,
modelContextWindow : model.contextWindow ,
defaultTokens : DEFAULT_CONTEXT_TOKENS ,
} ) ;
const ctxGuard = evaluateContextWindowGuard ( {
info : ctxInfo ,
warnBelowTokens : CONTEXT_WINDOW_WARN_BELOW_TOKENS ,
hardMinTokens : CONTEXT_WINDOW_HARD_MIN_TOKENS ,
} ) ;
if ( ctxGuard . shouldWarn ) {
log . warn (
` low context window: ${ provider } / ${ modelId } ctx= ${ ctxGuard . tokens } (warn< ${ CONTEXT_WINDOW_WARN_BELOW_TOKENS } ) source= ${ ctxGuard . source } ` ,
) ;
}
if ( ctxGuard . shouldBlock ) {
log . error (
` blocked model (context window too small): ${ provider } / ${ modelId } ctx= ${ ctxGuard . tokens } (min= ${ CONTEXT_WINDOW_HARD_MIN_TOKENS } ) source= ${ ctxGuard . source } ` ,
) ;
throw new FailoverError (
` Model context window too small ( ${ ctxGuard . tokens } tokens). Minimum is ${ CONTEXT_WINDOW_HARD_MIN_TOKENS } . ` ,
{ reason : "unknown" , provider , model : modelId } ,
) ;
}
2026-01-18 04:18:58 +00:00
const authStore = ensureAuthProfileStore ( agentDir , { allowKeychainPrompt : false } ) ;
2026-01-18 08:22:50 +00:00
const preferredProfileId = params . authProfileId ? . trim ( ) ;
2026-01-21 06:00:16 +00:00
let lockedProfileId = params . authProfileIdSource === "user" ? preferredProfileId : undefined ;
if ( lockedProfileId ) {
const lockedProfile = authStore . profiles [ lockedProfileId ] ;
if (
! lockedProfile ||
normalizeProviderId ( lockedProfile . provider ) !== normalizeProviderId ( provider )
) {
lockedProfileId = undefined ;
}
}
2026-01-14 01:08:15 +00:00
const profileOrder = resolveAuthProfileOrder ( {
cfg : params.config ,
store : authStore ,
provider ,
2026-01-18 08:22:50 +00:00
preferredProfile : preferredProfileId ,
2026-01-14 01:08:15 +00:00
} ) ;
2026-01-18 08:22:50 +00:00
if ( lockedProfileId && ! profileOrder . includes ( lockedProfileId ) ) {
throw new Error ( ` Auth profile " ${ lockedProfileId } " is not configured for ${ provider } . ` ) ;
2026-01-14 01:08:15 +00:00
}
2026-01-23 03:05:01 +00:00
const profileCandidates = lockedProfileId
? [ lockedProfileId ]
: profileOrder . length > 0
? profileOrder
: [ undefined ] ;
2026-01-14 01:08:15 +00:00
let profileIndex = 0 ;
const initialThinkLevel = params . thinkLevel ? ? "off" ;
let thinkLevel = initialThinkLevel ;
const attemptedThinking = new Set < ThinkLevel > ( ) ;
let apiKeyInfo : ApiKeyInfo | null = null ;
let lastProfileId : string | undefined ;
2026-01-24 06:14:17 +00:00
const resolveAuthProfileFailoverReason = ( params : {
allInCooldown : boolean ;
message : string ;
} ) : FailoverReason = > {
2026-01-31 16:19:20 +09:00
if ( params . allInCooldown ) {
return "rate_limit" ;
}
2026-01-24 06:14:17 +00:00
const classified = classifyFailoverReason ( params . message ) ;
return classified ? ? "auth" ;
} ;
const throwAuthProfileFailover = ( params : {
allInCooldown : boolean ;
message? : string ;
error? : unknown ;
} ) : never = > {
const fallbackMessage = ` No available auth profile for ${ provider } (all in cooldown or unavailable). ` ;
const message =
params . message ? . trim ( ) ||
( params . error ? describeUnknownError ( params . error ) . trim ( ) : "" ) ||
fallbackMessage ;
const reason = resolveAuthProfileFailoverReason ( {
allInCooldown : params.allInCooldown ,
message ,
} ) ;
if ( fallbackConfigured ) {
throw new FailoverError ( message , {
reason ,
provider ,
model : modelId ,
status : resolveFailoverStatus ( reason ) ,
cause : params.error ,
} ) ;
}
2026-01-31 16:19:20 +09:00
if ( params . error instanceof Error ) {
throw params . error ;
}
2026-01-24 06:14:17 +00:00
throw new Error ( message ) ;
} ;
2026-01-14 01:08:15 +00:00
const resolveApiKeyForCandidate = async ( candidate? : string ) = > {
return getApiKeyForModel ( {
model ,
cfg : params.config ,
profileId : candidate ,
store : authStore ,
2026-01-15 04:41:50 +00:00
agentDir ,
2026-01-14 01:08:15 +00:00
} ) ;
} ;
const applyApiKeyInfo = async ( candidate? : string ) : Promise < void > = > {
apiKeyInfo = await resolveApiKeyForCandidate ( candidate ) ;
2026-01-23 03:05:01 +00:00
const resolvedProfileId = apiKeyInfo . profileId ? ? candidate ;
2026-01-20 07:53:25 +00:00
if ( ! apiKeyInfo . apiKey ) {
if ( apiKeyInfo . mode !== "aws-sdk" ) {
throw new Error (
` No API key resolved for provider " ${ model . provider } " (auth mode: ${ apiKeyInfo . mode } ). ` ,
) ;
}
2026-01-23 03:05:01 +00:00
lastProfileId = resolvedProfileId ;
2026-01-20 07:53:25 +00:00
return ;
}
2026-01-23 07:12:01 +00:00
if ( model . provider === "github-copilot" ) {
const { resolveCopilotApiToken } =
await import ( "../../providers/github-copilot-token.js" ) ;
const copilotToken = await resolveCopilotApiToken ( {
githubToken : apiKeyInfo.apiKey ,
} ) ;
authStorage . setRuntimeApiKey ( model . provider , copilotToken . token ) ;
} else {
authStorage . setRuntimeApiKey ( model . provider , apiKeyInfo . apiKey ) ;
}
lastProfileId = apiKeyInfo . profileId ;
2026-01-14 01:08:15 +00:00
} ;
const advanceAuthProfile = async ( ) : Promise < boolean > = > {
2026-01-31 16:19:20 +09:00
if ( lockedProfileId ) {
return false ;
}
2026-01-14 01:08:15 +00:00
let nextIndex = profileIndex + 1 ;
while ( nextIndex < profileCandidates . length ) {
const candidate = profileCandidates [ nextIndex ] ;
2026-01-22 10:04:56 +01:00
if ( candidate && isProfileInCooldown ( authStore , candidate ) ) {
nextIndex += 1 ;
continue ;
}
2026-01-14 01:08:15 +00:00
try {
await applyApiKeyInfo ( candidate ) ;
profileIndex = nextIndex ;
thinkLevel = initialThinkLevel ;
attemptedThinking . clear ( ) ;
return true ;
} catch ( err ) {
2026-01-31 16:19:20 +09:00
if ( candidate && candidate === lockedProfileId ) {
throw err ;
}
2026-01-14 01:08:15 +00:00
nextIndex += 1 ;
}
}
return false ;
} ;
try {
2026-01-22 10:04:56 +01:00
while ( profileIndex < profileCandidates . length ) {
const candidate = profileCandidates [ profileIndex ] ;
2026-01-23 03:05:01 +00:00
if (
candidate &&
candidate !== lockedProfileId &&
isProfileInCooldown ( authStore , candidate )
) {
2026-01-22 10:04:56 +01:00
profileIndex += 1 ;
continue ;
}
await applyApiKeyInfo ( profileCandidates [ profileIndex ] ) ;
break ;
}
if ( profileIndex >= profileCandidates . length ) {
2026-01-24 06:14:17 +00:00
throwAuthProfileFailover ( { allInCooldown : true } ) ;
2026-01-22 10:04:56 +01:00
}
2026-01-14 01:08:15 +00:00
} catch ( err ) {
2026-01-31 16:19:20 +09:00
if ( err instanceof FailoverError ) {
throw err ;
}
2026-01-24 06:14:17 +00:00
if ( profileCandidates [ profileIndex ] === lockedProfileId ) {
throwAuthProfileFailover ( { allInCooldown : false , error : err } ) ;
}
2026-01-14 01:08:15 +00:00
const advanced = await advanceAuthProfile ( ) ;
2026-01-24 06:14:17 +00:00
if ( ! advanced ) {
throwAuthProfileFailover ( { allInCooldown : false , error : err } ) ;
}
2026-01-14 01:08:15 +00:00
}
2026-02-05 17:58:37 -04:00
const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3 ;
let overflowCompactionAttempts = 0 ;
2026-02-07 17:40:51 -08:00
let toolResultTruncationAttempted = false ;
2026-02-07 20:02:32 -08:00
const usageAccumulator = createUsageAccumulator ( ) ;
2026-02-13 00:53:13 +01:00
let lastRunPromptUsage : ReturnType < typeof normalizeUsage > | undefined ;
2026-02-07 20:02:32 -08:00
let autoCompactionCount = 0 ;
2026-01-14 01:08:15 +00:00
try {
while ( true ) {
attemptedThinking . add ( thinkLevel ) ;
await fs . mkdir ( resolvedWorkspace , { recursive : true } ) ;
2026-01-21 07:28:11 +00:00
const prompt =
provider === "anthropic" ? scrubAnthropicRefusalMagic ( params . prompt ) : params . prompt ;
2026-01-14 01:08:15 +00:00
const attempt = await runEmbeddedAttempt ( {
sessionId : params.sessionId ,
sessionKey : params.sessionKey ,
messageChannel : params.messageChannel ,
messageProvider : params.messageProvider ,
agentAccountId : params.agentAccountId ,
2026-01-20 17:22:07 +00:00
messageTo : params.messageTo ,
messageThreadId : params.messageThreadId ,
2026-01-24 15:35:05 +13:00
groupId : params.groupId ,
groupChannel : params.groupChannel ,
groupSpace : params.groupSpace ,
2026-01-24 05:49:23 +00:00
spawnedBy : params.spawnedBy ,
2026-02-04 19:49:36 -05:00
senderIsOwner : params.senderIsOwner ,
2026-01-14 01:08:15 +00:00
currentChannelId : params.currentChannelId ,
currentThreadTs : params.currentThreadTs ,
replyToMode : params.replyToMode ,
hasRepliedRef : params.hasRepliedRef ,
sessionFile : params.sessionFile ,
2026-02-07 01:16:58 +07:00
workspaceDir : resolvedWorkspace ,
2026-01-14 01:08:15 +00:00
agentDir ,
config : params.config ,
skillsSnapshot : params.skillsSnapshot ,
2026-01-21 07:28:11 +00:00
prompt ,
2026-01-14 01:08:15 +00:00
images : params.images ,
2026-01-24 01:44:36 +00:00
disableTools : params.disableTools ,
2026-01-14 01:08:15 +00:00
provider ,
modelId ,
model ,
authStorage ,
modelRegistry ,
2026-02-07 01:16:58 +07:00
agentId : workspaceResolution.agentId ,
2026-01-14 01:08:15 +00:00
thinkLevel ,
verboseLevel : params.verboseLevel ,
reasoningLevel : params.reasoningLevel ,
2026-01-17 10:17:57 +00:00
toolResultFormat : resolvedToolResultFormat ,
2026-01-18 06:11:38 +00:00
execOverrides : params.execOverrides ,
2026-01-14 01:08:15 +00:00
bashElevated : params.bashElevated ,
timeoutMs : params.timeoutMs ,
runId : params.runId ,
abortSignal : params.abortSignal ,
shouldEmitToolResult : params.shouldEmitToolResult ,
2026-01-17 05:33:27 +00:00
shouldEmitToolOutput : params.shouldEmitToolOutput ,
2026-01-14 01:08:15 +00:00
onPartialReply : params.onPartialReply ,
onAssistantMessageStart : params.onAssistantMessageStart ,
onBlockReply : params.onBlockReply ,
onBlockReplyFlush : params.onBlockReplyFlush ,
blockReplyBreak : params.blockReplyBreak ,
blockReplyChunking : params.blockReplyChunking ,
onReasoningStream : params.onReasoningStream ,
2026-02-16 21:24:34 +08:00
onReasoningEnd : params.onReasoningEnd ,
2026-01-14 01:08:15 +00:00
onToolResult : params.onToolResult ,
onAgentEvent : params.onAgentEvent ,
extraSystemPrompt : params.extraSystemPrompt ,
2026-02-13 02:01:53 +01:00
inputProvenance : params.inputProvenance ,
2026-01-20 07:35:29 +00:00
streamParams : params.streamParams ,
2026-01-14 01:08:15 +00:00
ownerNumbers : params.ownerNumbers ,
enforceFinalTag : params.enforceFinalTag ,
} ) ;
2026-02-14 14:24:20 -05:00
const {
aborted ,
promptError ,
timedOut ,
timedOutDuringCompaction ,
sessionIdUsed ,
lastAssistant ,
} = attempt ;
2026-02-13 00:53:13 +01:00
const lastAssistantUsage = normalizeUsage ( lastAssistant ? . usage as UsageLike ) ;
const attemptUsage = attempt . attemptUsage ? ? lastAssistantUsage ;
mergeUsageIntoAccumulator ( usageAccumulator , attemptUsage ) ;
// Keep prompt size from the latest model call so session totalTokens
// reflects current context usage, not accumulated tool-loop usage.
lastRunPromptUsage = lastAssistantUsage ? ? attemptUsage ;
2026-02-16 13:44:22 +01:00
const lastTurnTotal = lastAssistantUsage ? . total ? ? attemptUsage ? . total ;
Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447)
* Agents: add subagent orchestration controls
* Agents: add subagent orchestration controls (WIP uncommitted changes)
* feat(subagents): add depth-based spawn gating for sub-sub-agents
* feat(subagents): tool policy, registry, and announce chain for nested agents
* feat(subagents): system prompt, docs, changelog for nested sub-agents
* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback
Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.
Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.
Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.
* fix(subagents): track spawn depth in session store and fix announce routing for nested agents
* Fix compaction status tracking and dedupe overflow compaction triggers
* fix(subagents): enforce depth block via session store and implement cascade kill
* fix: inject group chat context into system prompt
* fix(subagents): always write model to session store at spawn time
* Preserve spawnDepth when agent handler rewrites session entry
* fix(subagents): suppress announce on steer-restart
* fix(subagents): fallback spawned session model to runtime default
* fix(subagents): enforce spawn depth when caller key resolves by sessionId
* feat(subagents): implement active-first ordering for numeric targets and enhance task display
- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.
* fix(subagents): show model for active runs via run record fallback
When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.
Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.
Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay
* feat(chat): implement session key resolution and reset on sidebar navigation
- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.
* fix: subagent timeout=0 passthrough and fallback prompt duplication
Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
0 → MAX_SAFE_TIMEOUT_MS)
Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
message instead of the full original prompt since the session file already
contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)
* feat(subagents): truncate long task descriptions in subagents command output
- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.
* refactor(subagents): update subagent registry path resolution and improve command output formatting
- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.
* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted
The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.
undefined flowed through the chain as:
sessions_spawn → timeout: undefined (since undefined != null is false)
→ gateway agent handler → agentCommand opts.timeout: undefined
→ resolveAgentTimeoutMs({ overrideSeconds: undefined })
→ DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)
This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.
Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.
* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)
* fix: thread timeout override through getReplyFromConfig dispatch path
getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).
This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.
* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling
- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.
* feat(tests): add unit tests for steer failure behavior in openclaw-tools
- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.
* fix(subagents): replace stop command with kill in slash commands and documentation
- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.
* feat(tests): add unit tests for readLatestAssistantReply function
- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.
* feat(tests): enhance subagent kill-all cascade tests and announce formatting
- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.
* refactor(subagent): update announce formatting and remove unused constants
- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.
* feat(tests): enhance billing error handling in user-facing text
- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.
* feat(subagent): enhance workflow guidance and auto-announcement clarity
- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.
* fix(cron): avoid announcing interim subagent spawn acks
* chore: clean post-rebase imports
* fix(cron): fall back to child replies when parent stays interim
* fix(subagents): make active-run guidance advisory
* fix(subagents): update announce flow to handle active descendants and enhance test coverage
- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.
* fix(subagents): enhance announce flow and formatting for user updates
- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.
* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)
* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)
* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)
* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
2026-02-14 22:03:45 -08:00
const attemptCompactionCount = Math . max ( 0 , attempt . compactionCount ? ? 0 ) ;
autoCompactionCount += attemptCompactionCount ;
2026-02-19 10:56:00 +08:00
const activeErrorContext = resolveActiveErrorContext ( {
lastAssistant ,
provider ,
model : modelId ,
} ) ;
2026-02-07 20:02:32 -08:00
const formattedAssistantErrorText = lastAssistant
? formatAssistantErrorText ( lastAssistant , {
cfg : params.config ,
sessionKey : params.sessionKey ? ? params . sessionId ,
2026-02-19 10:56:00 +08:00
provider : activeErrorContext.provider ,
model : activeErrorContext.model ,
2026-02-07 20:02:32 -08:00
} )
: undefined ;
const assistantErrorText =
lastAssistant ? . stopReason === "error"
? lastAssistant . errorMessage ? . trim ( ) || formattedAssistantErrorText
: undefined ;
const contextOverflowError = ! aborted
? ( ( ) = > {
if ( promptError ) {
const errorText = describeUnknownError ( promptError ) ;
2026-02-13 00:53:13 +01:00
if ( isLikelyContextOverflowError ( errorText ) ) {
2026-02-07 20:02:32 -08:00
return { text : errorText , source : "promptError" as const } ;
}
// Prompt submission failed with a non-overflow error. Do not
// inspect prior assistant errors from history for this attempt.
return null ;
}
2026-02-13 00:53:13 +01:00
if ( assistantErrorText && isLikelyContextOverflowError ( assistantErrorText ) ) {
2026-02-07 20:02:32 -08:00
return { text : assistantErrorText , source : "assistantError" as const } ;
}
return null ;
} ) ( )
: null ;
2026-01-14 01:08:15 +00:00
2026-02-07 20:02:32 -08:00
if ( contextOverflowError ) {
2026-02-13 19:54:22 -04:00
const overflowDiagId = createCompactionDiagId ( ) ;
2026-02-07 20:02:32 -08:00
const errorText = contextOverflowError . text ;
const msgCount = attempt . messagesSnapshot ? . length ? ? 0 ;
log . warn (
` [context-overflow-diag] sessionKey= ${ params . sessionKey ? ? params . sessionId } ` +
` provider= ${ provider } / ${ modelId } source= ${ contextOverflowError . source } ` +
` messages= ${ msgCount } sessionFile= ${ params . sessionFile } ` +
2026-02-13 19:54:22 -04:00
` diagId= ${ overflowDiagId } compactionAttempts= ${ overflowCompactionAttempts } ` +
` error= ${ errorText . slice ( 0 , 200 ) } ` ,
2026-02-07 20:02:32 -08:00
) ;
const isCompactionFailure = isCompactionFailureError ( errorText ) ;
Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447)
* Agents: add subagent orchestration controls
* Agents: add subagent orchestration controls (WIP uncommitted changes)
* feat(subagents): add depth-based spawn gating for sub-sub-agents
* feat(subagents): tool policy, registry, and announce chain for nested agents
* feat(subagents): system prompt, docs, changelog for nested sub-agents
* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback
Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.
Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.
Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.
* fix(subagents): track spawn depth in session store and fix announce routing for nested agents
* Fix compaction status tracking and dedupe overflow compaction triggers
* fix(subagents): enforce depth block via session store and implement cascade kill
* fix: inject group chat context into system prompt
* fix(subagents): always write model to session store at spawn time
* Preserve spawnDepth when agent handler rewrites session entry
* fix(subagents): suppress announce on steer-restart
* fix(subagents): fallback spawned session model to runtime default
* fix(subagents): enforce spawn depth when caller key resolves by sessionId
* feat(subagents): implement active-first ordering for numeric targets and enhance task display
- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.
* fix(subagents): show model for active runs via run record fallback
When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.
Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.
Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay
* feat(chat): implement session key resolution and reset on sidebar navigation
- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.
* fix: subagent timeout=0 passthrough and fallback prompt duplication
Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
0 → MAX_SAFE_TIMEOUT_MS)
Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
message instead of the full original prompt since the session file already
contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)
* feat(subagents): truncate long task descriptions in subagents command output
- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.
* refactor(subagents): update subagent registry path resolution and improve command output formatting
- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.
* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted
The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.
undefined flowed through the chain as:
sessions_spawn → timeout: undefined (since undefined != null is false)
→ gateway agent handler → agentCommand opts.timeout: undefined
→ resolveAgentTimeoutMs({ overrideSeconds: undefined })
→ DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)
This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.
Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.
* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)
* fix: thread timeout override through getReplyFromConfig dispatch path
getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).
This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.
* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling
- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.
* feat(tests): add unit tests for steer failure behavior in openclaw-tools
- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.
* fix(subagents): replace stop command with kill in slash commands and documentation
- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.
* feat(tests): add unit tests for readLatestAssistantReply function
- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.
* feat(tests): enhance subagent kill-all cascade tests and announce formatting
- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.
* refactor(subagent): update announce formatting and remove unused constants
- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.
* feat(tests): enhance billing error handling in user-facing text
- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.
* feat(subagent): enhance workflow guidance and auto-announcement clarity
- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.
* fix(cron): avoid announcing interim subagent spawn acks
* chore: clean post-rebase imports
* fix(cron): fall back to child replies when parent stays interim
* fix(subagents): make active-run guidance advisory
* fix(subagents): update announce flow to handle active descendants and enhance test coverage
- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.
* fix(subagents): enhance announce flow and formatting for user updates
- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.
* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)
* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)
* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)
* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
2026-02-14 22:03:45 -08:00
const hadAttemptLevelCompaction = attemptCompactionCount > 0 ;
// If this attempt already compacted (SDK auto-compaction), avoid immediately
// running another explicit compaction for the same overflow trigger.
2026-02-07 20:02:32 -08:00
if (
! isCompactionFailure &&
Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447)
* Agents: add subagent orchestration controls
* Agents: add subagent orchestration controls (WIP uncommitted changes)
* feat(subagents): add depth-based spawn gating for sub-sub-agents
* feat(subagents): tool policy, registry, and announce chain for nested agents
* feat(subagents): system prompt, docs, changelog for nested sub-agents
* fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback
Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex),
the fallback candidate logic in resolveFallbackCandidates silently appended the
global primary model (opus) as a backstop. On reinjection/steer with a transient
error, the session could fall back to opus which has a smaller context window
and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? []
instead of undefined, preventing the implicit primary backstop.
Bug 2: Active subagents showed 'model n/a' in /subagents list because
resolveModelDisplay only read entry.model/modelProvider (populated after run
completes). Fix: fall back to modelOverride/providerOverride fields which are
populated at spawn time via sessions.patch.
Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could
theoretically escape runEmbeddedPiAgent and be treated as failover candidates
in runWithModelFallback, causing a switch to a model with a smaller context
window. Fix: in runWithModelFallback, detect context overflow errors via
isLikelyContextOverflowError and rethrow them immediately instead of trying the
next model candidate.
* fix(subagents): track spawn depth in session store and fix announce routing for nested agents
* Fix compaction status tracking and dedupe overflow compaction triggers
* fix(subagents): enforce depth block via session store and implement cascade kill
* fix: inject group chat context into system prompt
* fix(subagents): always write model to session store at spawn time
* Preserve spawnDepth when agent handler rewrites session entry
* fix(subagents): suppress announce on steer-restart
* fix(subagents): fallback spawned session model to runtime default
* fix(subagents): enforce spawn depth when caller key resolves by sessionId
* feat(subagents): implement active-first ordering for numeric targets and enhance task display
- Added a test to verify that subagents with numeric targets follow an active-first list ordering.
- Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity.
- Enhanced task display in command responses to prevent truncation of long task descriptions.
- Introduced new utility functions for compacting task text and managing subagent run states.
* fix(subagents): show model for active runs via run record fallback
When the spawned model matches the agent's default model, the session
store's override fields are intentionally cleared (isDefault: true).
The model/modelProvider fields are only populated after the run
completes. This left active subagents showing 'model n/a'.
Fix: store the resolved model on SubagentRunRecord at registration
time, and use it as a fallback in both display paths (subagents tool
and /subagents command) when the session store entry has no model info.
Changes:
- SubagentRunRecord: add optional model field
- registerSubagentRun: accept and persist model param
- sessions-spawn-tool: pass resolvedModel to registerSubagentRun
- subagents-tool: pass run record model as fallback to resolveModelDisplay
- commands-subagents: pass run record model as fallback to resolveModelDisplay
* feat(chat): implement session key resolution and reset on sidebar navigation
- Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar.
- Updated the `renderTab` function to handle session key changes when navigating to the chat tab.
- Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation.
* fix: subagent timeout=0 passthrough and fallback prompt duplication
Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default
- sessions-spawn-tool: default to undefined (not 0) when neither timeout param
is provided; use != null check so explicit 0 passes through to gateway
- agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles
0 → MAX_SAFE_TIMEOUT_MS)
Bug 2: model fallback no longer re-injects the original prompt as a duplicate
- agent.ts: track fallback attempt index; on retries use a short continuation
message instead of the full original prompt since the session file already
contains it from the first attempt
- Also skip re-sending images on fallback retries (already in session)
* feat(subagents): truncate long task descriptions in subagents command output
- Introduced a new utility function to format task previews, limiting their length to improve readability.
- Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately.
- Adjusted related tests to verify that long task descriptions are now truncated in the output.
* refactor(subagents): update subagent registry path resolution and improve command output formatting
- Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically.
- Enhanced the formatting of command output for active and recent subagents, adding separators for better readability.
- Updated related tests to reflect changes in command output structure.
* fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted
The previous fix (75a791106) correctly handled the case where
runTimeoutSeconds was explicitly set to 0 ("no timeout"). However,
when models omit the parameter entirely (which is common since the
schema marks it as optional), runTimeoutSeconds resolved to undefined.
undefined flowed through the chain as:
sessions_spawn → timeout: undefined (since undefined != null is false)
→ gateway agent handler → agentCommand opts.timeout: undefined
→ resolveAgentTimeoutMs({ overrideSeconds: undefined })
→ DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes)
This caused subagents to be killed at exactly 10 minutes even though
the user's intent (via TOOLS.md) was for subagents to run without a
timeout.
Fix: default runTimeoutSeconds to 0 (no timeout) when neither
runTimeoutSeconds nor timeoutSeconds is provided by the caller.
Subagent spawns are long-running by design and should not inherit the
600s agent-command default timeout.
* fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default)
* fix: thread timeout override through getReplyFromConfig dispatch path
getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override,
always falling back to the config default (600s). Add timeoutOverrideSeconds
to GetReplyOptions and pass it through as overrideSeconds so callers of the
dispatch chain can specify a custom timeout (0 = no timeout).
This complements the existing timeout threading in agentCommand and the
cron isolated-agent runner, which already pass overrideSeconds correctly.
* feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling
- Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution.
- Updated the `resolveFallbackCandidates` function to utilize the new normalization logic.
- Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms.
- Introduced a new test case to ensure that the normalization process works as expected for various input formats.
* feat(tests): add unit tests for steer failure behavior in openclaw-tools
- Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails.
- Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected.
- Enhanced the subagent registry with a new function to clear steer restart suppression.
- Updated related components to support the new test scenarios.
* fix(subagents): replace stop command with kill in slash commands and documentation
- Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs.
- Modified related documentation to reflect the change in command usage.
- Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling.
- Enhanced tests to ensure correct behavior of the updated commands and their interactions.
* feat(tests): add unit tests for readLatestAssistantReply function
- Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios.
- Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text.
- Mocked the gateway call to simulate different message histories for comprehensive testing.
* feat(tests): enhance subagent kill-all cascade tests and announce formatting
- Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents.
- Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content.
- Improved the handling of long findings and stats in the announce formatting logic to ensure concise output.
- Refactored related functions to enhance clarity and maintainability in the subagent registry and tools.
* refactor(subagent): update announce formatting and remove unused constants
- Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests.
- Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic.
- Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs.
- Cleaned up unused imports in the commands-subagents file to enhance code clarity.
* feat(tests): enhance billing error handling in user-facing text
- Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context.
- Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages.
- Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output.
- Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements.
* feat(subagent): enhance workflow guidance and auto-announcement clarity
- Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates.
- Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow.
- Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts.
* fix(cron): avoid announcing interim subagent spawn acks
* chore: clean post-rebase imports
* fix(cron): fall back to child replies when parent stays interim
* fix(subagents): make active-run guidance advisory
* fix(subagents): update announce flow to handle active descendants and enhance test coverage
- Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting.
- Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents.
- Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process.
* fix(subagents): enhance announce flow and formatting for user updates
- Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context.
- Refactored the announcement logic to improve clarity and ensure internal context remains private.
- Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates.
- Introduced a new function to build reply instructions based on session context, improving the overall announcement process.
* fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204)
* fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204)
* fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204)
* fix: apply formatting after rebase (#14447) (thanks @tyler6204)
2026-02-14 22:03:45 -08:00
hadAttemptLevelCompaction &&
overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
) {
overflowCompactionAttempts ++ ;
log . warn (
` context overflow persisted after in-attempt compaction (attempt ${ overflowCompactionAttempts } / ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ); retrying prompt without additional compaction for ${ provider } / ${ modelId } ` ,
) ;
continue ;
}
// Attempt explicit overflow compaction only when this attempt did not
// already auto-compact.
if (
! isCompactionFailure &&
! hadAttemptLevelCompaction &&
2026-02-07 20:02:32 -08:00
overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
) {
2026-02-13 19:54:22 -04:00
if ( log . isEnabled ( "debug" ) ) {
log . debug (
` [compaction-diag] decision diagId= ${ overflowDiagId } branch=compact ` +
` isCompactionFailure= ${ isCompactionFailure } hasOversizedToolResults=unknown ` +
` attempt= ${ overflowCompactionAttempts + 1 } maxAttempts= ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ` ,
) ;
}
2026-02-07 20:02:32 -08:00
overflowCompactionAttempts ++ ;
2026-02-05 17:58:37 -04:00
log . warn (
2026-02-07 20:02:32 -08:00
` context overflow detected (attempt ${ overflowCompactionAttempts } / ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ); attempting auto-compaction for ${ provider } / ${ modelId } ` ,
2026-02-05 17:58:37 -04:00
) ;
2026-02-07 20:02:32 -08:00
const compactResult = await compactEmbeddedPiSessionDirect ( {
sessionId : params.sessionId ,
sessionKey : params.sessionKey ,
messageChannel : params.messageChannel ,
messageProvider : params.messageProvider ,
agentAccountId : params.agentAccountId ,
authProfileId : lastProfileId ,
sessionFile : params.sessionFile ,
workspaceDir : resolvedWorkspace ,
agentDir ,
config : params.config ,
skillsSnapshot : params.skillsSnapshot ,
senderIsOwner : params.senderIsOwner ,
provider ,
model : modelId ,
2026-02-13 19:54:22 -04:00
runId : params.runId ,
2026-02-07 20:02:32 -08:00
thinkLevel ,
reasoningLevel : params.reasoningLevel ,
bashElevated : params.bashElevated ,
extraSystemPrompt : params.extraSystemPrompt ,
ownerNumbers : params.ownerNumbers ,
2026-02-13 19:54:22 -04:00
trigger : "overflow" ,
diagId : overflowDiagId ,
attempt : overflowCompactionAttempts ,
maxAttempts : MAX_OVERFLOW_COMPACTION_ATTEMPTS ,
2026-02-07 20:02:32 -08:00
} ) ;
if ( compactResult . compacted ) {
autoCompactionCount += 1 ;
log . info ( ` auto-compaction succeeded for ${ provider } / ${ modelId } ; retrying prompt ` ) ;
continue ;
}
log . warn (
` auto-compaction failed for ${ provider } / ${ modelId } : ${ compactResult . reason ? ? "nothing to compact" } ` ,
) ;
}
// Fallback: try truncating oversized tool results in the session.
// This handles the case where a single tool result exceeds the
// context window and compaction cannot reduce it further.
if ( ! toolResultTruncationAttempted ) {
const contextWindowTokens = ctxInfo . tokens ;
const hasOversized = attempt . messagesSnapshot
? sessionLikelyHasOversizedToolResults ( {
messages : attempt.messagesSnapshot ,
contextWindowTokens ,
} )
: false ;
if ( hasOversized ) {
2026-02-13 19:54:22 -04:00
if ( log . isEnabled ( "debug" ) ) {
log . debug (
` [compaction-diag] decision diagId= ${ overflowDiagId } branch=truncate_tool_results ` +
` isCompactionFailure= ${ isCompactionFailure } hasOversizedToolResults= ${ hasOversized } ` +
` attempt= ${ overflowCompactionAttempts } maxAttempts= ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ` ,
) ;
}
2026-02-07 20:02:32 -08:00
toolResultTruncationAttempted = true ;
2026-01-24 19:09:24 -03:00
log . warn (
2026-02-07 20:02:32 -08:00
` [context-overflow-recovery] Attempting tool result truncation for ${ provider } / ${ modelId } ` +
` (contextWindow= ${ contextWindowTokens } tokens) ` ,
2026-01-24 19:09:24 -03:00
) ;
2026-02-07 20:02:32 -08:00
const truncResult = await truncateOversizedToolResultsInSession ( {
sessionFile : params.sessionFile ,
contextWindowTokens ,
2026-01-24 19:09:24 -03:00
sessionId : params.sessionId ,
sessionKey : params.sessionKey ,
} ) ;
2026-02-07 20:02:32 -08:00
if ( truncResult . truncated ) {
log . info (
` [context-overflow-recovery] Truncated ${ truncResult . truncatedCount } tool result(s); retrying prompt ` ,
) ;
2026-02-18 15:27:57 +11:00
// Do NOT reset overflowCompactionAttempts here — the global cap must remain
// enforced across all iterations to prevent unbounded compaction cycles (OC-65).
2026-01-24 19:09:24 -03:00
continue ;
}
log . warn (
2026-02-07 20:02:32 -08:00
` [context-overflow-recovery] Tool result truncation did not help: ${ truncResult . reason ? ? "unknown" } ` ,
2026-01-24 19:09:24 -03:00
) ;
2026-02-13 19:54:22 -04:00
} else if ( log . isEnabled ( "debug" ) ) {
log . debug (
` [compaction-diag] decision diagId= ${ overflowDiagId } branch=give_up ` +
` isCompactionFailure= ${ isCompactionFailure } hasOversizedToolResults= ${ hasOversized } ` +
` attempt= ${ overflowCompactionAttempts } maxAttempts= ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ` ,
) ;
2026-01-24 19:09:24 -03:00
}
2026-01-14 01:08:15 +00:00
}
2026-02-13 19:54:22 -04:00
if (
( isCompactionFailure ||
overflowCompactionAttempts >= MAX_OVERFLOW_COMPACTION_ATTEMPTS ||
toolResultTruncationAttempted ) &&
log . isEnabled ( "debug" )
) {
log . debug (
` [compaction-diag] decision diagId= ${ overflowDiagId } branch=give_up ` +
` isCompactionFailure= ${ isCompactionFailure } hasOversizedToolResults=unknown ` +
` attempt= ${ overflowCompactionAttempts } maxAttempts= ${ MAX_OVERFLOW_COMPACTION_ATTEMPTS } ` ,
) ;
}
2026-02-07 20:02:32 -08:00
const kind = isCompactionFailure ? "compaction_failure" : "context_overflow" ;
return {
payloads : [
{
text :
"Context overflow: prompt too large for the model. " +
2026-02-09 20:44:37 -06:00
"Try /reset (or /new) to start a fresh session, or use a larger-context model." ,
2026-02-07 20:02:32 -08:00
isError : true ,
} ,
] ,
meta : {
durationMs : Date.now ( ) - started ,
agentMeta : {
sessionId : sessionIdUsed ,
provider ,
model : model.id ,
} ,
systemPromptReport : attempt.systemPromptReport ,
error : { kind , message : errorText } ,
} ,
} ;
}
if ( promptError && ! aborted ) {
const errorText = describeUnknownError ( promptError ) ;
2026-01-16 03:00:40 +00:00
// Handle role ordering errors with a user-friendly message
if ( /incorrect role information|roles must alternate/i . test ( errorText ) ) {
return {
payloads : [
{
text :
"Message ordering conflict - please try again. " +
"If this persists, use /new to start a fresh session." ,
isError : true ,
} ,
] ,
meta : {
durationMs : Date.now ( ) - started ,
agentMeta : {
sessionId : sessionIdUsed ,
provider ,
model : model.id ,
} ,
2026-01-16 09:03:54 +00:00
systemPromptReport : attempt.systemPromptReport ,
error : { kind : "role_ordering" , message : errorText } ,
2026-01-16 03:00:40 +00:00
} ,
} ;
}
2026-01-27 22:21:51 +05:30
// Handle image size errors with a user-friendly message (no retry needed)
2026-01-27 15:59:11 -06:00
const imageSizeError = parseImageSizeError ( errorText ) ;
if ( imageSizeError ) {
const maxMb = imageSizeError . maxMb ;
const maxMbLabel =
typeof maxMb === "number" && Number . isFinite ( maxMb ) ? ` ${ maxMb } ` : null ;
const maxBytesHint = maxMbLabel ? ` (max ${ maxMbLabel } MB) ` : "" ;
2026-01-27 22:21:51 +05:30
return {
payloads : [
{
text :
2026-01-27 15:59:11 -06:00
` Image too large for the model ${ maxBytesHint } . ` +
2026-01-27 22:21:51 +05:30
"Please compress or resize the image and try again." ,
isError : true ,
} ,
] ,
meta : {
durationMs : Date.now ( ) - started ,
agentMeta : {
sessionId : sessionIdUsed ,
provider ,
model : model.id ,
} ,
systemPromptReport : attempt.systemPromptReport ,
error : { kind : "image_size" , message : errorText } ,
} ,
} ;
}
2026-01-14 01:08:15 +00:00
const promptFailoverReason = classifyFailoverReason ( errorText ) ;
2026-01-14 14:31:43 +00:00
if ( promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId ) {
2026-01-14 01:08:15 +00:00
await markAuthProfileFailure ( {
store : authStore ,
profileId : lastProfileId ,
reason : promptFailoverReason ,
cfg : params.config ,
agentDir : params.agentDir ,
} ) ;
}
if (
isFailoverErrorMessage ( errorText ) &&
promptFailoverReason !== "timeout" &&
( await advanceAuthProfile ( ) )
) {
continue ;
}
const fallbackThinking = pickFallbackThinkingLevel ( {
message : errorText ,
attempted : attemptedThinking ,
} ) ;
if ( fallbackThinking ) {
log . warn (
` unsupported thinking level for ${ provider } / ${ modelId } ; retrying with ${ fallbackThinking } ` ,
) ;
thinkLevel = fallbackThinking ;
continue ;
}
2026-01-18 01:29:48 +00:00
// FIX: Throw FailoverError for prompt errors when fallbacks configured
// This enables model fallback for quota/rate limit errors during prompt submission
2026-01-24 06:14:17 +00:00
if ( fallbackConfigured && isFailoverErrorMessage ( errorText ) ) {
2026-01-18 01:29:48 +00:00
throw new FailoverError ( errorText , {
reason : promptFailoverReason ? ? "unknown" ,
provider ,
model : modelId ,
profileId : lastProfileId ,
status : resolveFailoverStatus ( promptFailoverReason ? ? "unknown" ) ,
} ) ;
}
2026-01-14 01:08:15 +00:00
throw promptError ;
}
const fallbackThinking = pickFallbackThinkingLevel ( {
message : lastAssistant?.errorMessage ,
attempted : attemptedThinking ,
} ) ;
if ( fallbackThinking && ! aborted ) {
log . warn (
` unsupported thinking level for ${ provider } / ${ modelId } ; retrying with ${ fallbackThinking } ` ,
) ;
thinkLevel = fallbackThinking ;
continue ;
}
const authFailure = isAuthAssistantError ( lastAssistant ) ;
const rateLimitFailure = isRateLimitAssistantError ( lastAssistant ) ;
2026-02-05 17:58:43 -04:00
const billingFailure = isBillingAssistantError ( lastAssistant ) ;
2026-01-14 01:08:15 +00:00
const failoverFailure = isFailoverAssistantError ( lastAssistant ) ;
2026-01-14 14:31:43 +00:00
const assistantFailoverReason = classifyFailoverReason ( lastAssistant ? . errorMessage ? ? "" ) ;
2026-01-14 01:08:15 +00:00
const cloudCodeAssistFormatError = attempt . cloudCodeAssistFormatError ;
2026-01-18 15:19:25 +00:00
const imageDimensionError = parseImageDimensionError ( lastAssistant ? . errorMessage ? ? "" ) ;
if ( imageDimensionError && lastProfileId ) {
const details = [
imageDimensionError . messageIndex !== undefined
? ` message= ${ imageDimensionError . messageIndex } `
: null ,
imageDimensionError . contentIndex !== undefined
? ` content= ${ imageDimensionError . contentIndex } `
: null ,
imageDimensionError . maxDimensionPx !== undefined
? ` limit= ${ imageDimensionError . maxDimensionPx } px `
: null ,
]
. filter ( Boolean )
. join ( " " ) ;
log . warn (
` Profile ${ lastProfileId } rejected image payload ${ details ? ` ( ${ details } ) ` : "" } . ` ,
) ;
}
2026-01-14 01:08:15 +00:00
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
2026-02-14 14:24:20 -05:00
// But exclude post-prompt compaction timeouts (model succeeded; no profile issue)
const shouldRotate =
( ! aborted && failoverFailure ) || ( timedOut && ! timedOutDuringCompaction ) ;
2026-01-14 01:08:15 +00:00
if ( shouldRotate ) {
if ( lastProfileId ) {
const reason =
timedOut || assistantFailoverReason === "timeout"
? "timeout"
: ( assistantFailoverReason ? ? "unknown" ) ;
await markAuthProfileFailure ( {
store : authStore ,
profileId : lastProfileId ,
reason ,
cfg : params.config ,
agentDir : params.agentDir ,
} ) ;
2026-01-24 00:04:53 +00:00
if ( timedOut && ! isProbeSession ) {
2026-01-14 01:08:15 +00:00
log . warn (
` Profile ${ lastProfileId } timed out (possible rate limit). Trying next account... ` ,
) ;
}
if ( cloudCodeAssistFormatError ) {
log . warn (
` Profile ${ lastProfileId } hit Cloud Code Assist format error. Tool calls will be sanitized on retry. ` ,
) ;
}
}
const rotated = await advanceAuthProfile ( ) ;
2026-01-31 16:19:20 +09:00
if ( rotated ) {
continue ;
}
2026-01-14 01:08:15 +00:00
if ( fallbackConfigured ) {
2026-01-16 03:00:40 +00:00
// Prefer formatted error message (user-friendly) over raw errorMessage
2026-01-14 01:08:15 +00:00
const message =
( lastAssistant
? formatAssistantErrorText ( lastAssistant , {
cfg : params.config ,
sessionKey : params.sessionKey ? ? params . sessionId ,
2026-02-19 10:56:00 +08:00
provider : activeErrorContext.provider ,
model : activeErrorContext.model ,
2026-01-14 01:08:15 +00:00
} )
2026-01-16 03:00:40 +00:00
: undefined ) ||
lastAssistant ? . errorMessage ? . trim ( ) ||
2026-01-14 01:08:15 +00:00
( timedOut
? "LLM request timed out."
: rateLimitFailure
? "LLM request rate limited."
2026-02-05 17:58:43 -04:00
: billingFailure
2026-02-19 10:56:00 +08:00
? formatBillingErrorMessage (
activeErrorContext . provider ,
activeErrorContext . model ,
)
2026-02-05 17:58:43 -04:00
: authFailure
? "LLM request unauthorized."
: "LLM request failed." ) ;
2026-01-14 01:08:15 +00:00
const status =
resolveFailoverStatus ( assistantFailoverReason ? ? "unknown" ) ? ?
( isTimeoutErrorMessage ( message ) ? 408 : undefined ) ;
throw new FailoverError ( message , {
reason : assistantFailoverReason ? ? "unknown" ,
2026-02-19 10:56:00 +08:00
provider : activeErrorContext.provider ,
model : activeErrorContext.model ,
2026-01-14 01:08:15 +00:00
profileId : lastProfileId ,
status ,
} ) ;
}
}
2026-02-07 20:02:32 -08:00
const usage = toNormalizedUsage ( usageAccumulator ) ;
2026-02-16 13:44:22 +01:00
if ( usage && lastTurnTotal && lastTurnTotal > 0 ) {
usage . total = lastTurnTotal ;
}
2026-02-12 23:02:30 +00:00
// Extract the last individual API call's usage for context-window
// utilization display. The accumulated `usage` sums input tokens
// across all calls (tool-use loops, compaction retries), which
// overstates the actual context size. `lastCallUsage` reflects only
// the final call, giving an accurate snapshot of current context.
const lastCallUsage = normalizeUsage ( lastAssistant ? . usage as UsageLike ) ;
2026-02-13 00:53:13 +01:00
const promptTokens = derivePromptTokens ( lastRunPromptUsage ) ;
2026-01-14 01:08:15 +00:00
const agentMeta : EmbeddedPiAgentMeta = {
sessionId : sessionIdUsed ,
provider : lastAssistant?.provider ? ? provider ,
model : lastAssistant?.model ? ? model . id ,
usage ,
2026-02-12 23:02:30 +00:00
lastCallUsage : lastCallUsage ? ? undefined ,
2026-02-13 00:53:13 +01:00
promptTokens ,
2026-02-07 20:02:32 -08:00
compactionCount : autoCompactionCount > 0 ? autoCompactionCount : undefined ,
2026-01-14 01:08:15 +00:00
} ;
const payloads = buildEmbeddedRunPayloads ( {
assistantTexts : attempt.assistantTexts ,
toolMetas : attempt.toolMetas ,
lastAssistant : attempt.lastAssistant ,
2026-01-18 18:35:03 +05:30
lastToolError : attempt.lastToolError ,
2026-01-14 01:08:15 +00:00
config : params.config ,
sessionKey : params.sessionKey ? ? params . sessionId ,
2026-02-19 10:56:00 +08:00
provider : activeErrorContext.provider ,
model : activeErrorContext.model ,
2026-01-14 01:08:15 +00:00
verboseLevel : params.verboseLevel ,
reasoningLevel : params.reasoningLevel ,
2026-01-17 10:17:57 +00:00
toolResultFormat : resolvedToolResultFormat ,
2026-02-16 13:29:24 -06:00
suppressToolErrorWarnings : params.suppressToolErrorWarnings ,
2026-01-25 11:54:20 +00:00
inlineToolResultsAllowed : false ,
2026-01-14 01:08:15 +00:00
} ) ;
2026-02-14 20:32:45 -08:00
// Timeout aborts can leave the run without any assistant payloads.
// Emit an explicit timeout error instead of silently completing, so
// callers do not lose the turn as an orphaned user message.
if ( timedOut && ! timedOutDuringCompaction && payloads . length === 0 ) {
return {
payloads : [
{
text :
"Request timed out before a response was generated. " +
"Please try again, or increase `agents.defaults.timeoutSeconds` in your config." ,
isError : true ,
} ,
] ,
meta : {
durationMs : Date.now ( ) - started ,
agentMeta ,
aborted ,
systemPromptReport : attempt.systemPromptReport ,
} ,
didSendViaMessagingTool : attempt.didSendViaMessagingTool ,
messagingToolSentTexts : attempt.messagingToolSentTexts ,
2026-02-16 20:41:41 +01:00
messagingToolSentMediaUrls : attempt.messagingToolSentMediaUrls ,
2026-02-14 20:32:45 -08:00
messagingToolSentTargets : attempt.messagingToolSentTargets ,
2026-02-16 13:34:09 -08:00
successfulCronAdds : attempt.successfulCronAdds ,
2026-02-14 20:32:45 -08:00
} ;
}
2026-01-14 01:08:15 +00:00
log . debug (
` embedded run done: runId= ${ params . runId } sessionId= ${ params . sessionId } durationMs= ${ Date . now ( ) - started } aborted= ${ aborted } ` ,
) ;
if ( lastProfileId ) {
await markAuthProfileGood ( {
store : authStore ,
provider ,
profileId : lastProfileId ,
2026-01-23 03:05:01 +00:00
agentDir : params.agentDir ,
2026-01-14 01:08:15 +00:00
} ) ;
await markAuthProfileUsed ( {
store : authStore ,
profileId : lastProfileId ,
2026-01-23 03:05:01 +00:00
agentDir : params.agentDir ,
2026-01-14 01:08:15 +00:00
} ) ;
}
return {
payloads : payloads.length ? payloads : undefined ,
meta : {
durationMs : Date.now ( ) - started ,
agentMeta ,
aborted ,
2026-01-15 01:06:19 +00:00
systemPromptReport : attempt.systemPromptReport ,
2026-01-19 12:43:00 +01:00
// Handle client tool calls (OpenResponses hosted tools)
stopReason : attempt.clientToolCall ? "tool_calls" : undefined ,
pendingToolCalls : attempt.clientToolCall
? [
{
id : ` call_ ${ Date . now ( ) } ` ,
name : attempt.clientToolCall.name ,
arguments : JSON.stringify ( attempt . clientToolCall . params ) ,
} ,
]
: undefined ,
2026-01-14 01:08:15 +00:00
} ,
didSendViaMessagingTool : attempt.didSendViaMessagingTool ,
messagingToolSentTexts : attempt.messagingToolSentTexts ,
2026-02-16 20:41:41 +01:00
messagingToolSentMediaUrls : attempt.messagingToolSentMediaUrls ,
2026-01-14 01:08:15 +00:00
messagingToolSentTargets : attempt.messagingToolSentTargets ,
2026-02-16 13:34:09 -08:00
successfulCronAdds : attempt.successfulCronAdds ,
2026-01-14 01:08:15 +00:00
} ;
}
} finally {
process . chdir ( prevCwd ) ;
}
} ) ,
) ;
}