From 5d0b5388faa2c0f47a1180154a526f5607fa2d77 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:19:42 -0500 Subject: [PATCH] Fix active-memory config schema fallback mismatch (#65048) * fix(active-memory): remove built-in fallback model * fix active-memory config schema fallback fields * fix failover decision external abort typing --- extensions/active-memory/config.test.ts | 24 +++ extensions/active-memory/index.test.ts | 110 ++++++++++++- extensions/active-memory/index.ts | 151 +++++++++++++++--- extensions/active-memory/openclaw.plugin.json | 7 +- src/agents/pi-embedded-runner/run.ts | 5 + .../run/assistant-failover.ts | 4 +- src/agents/pi-embedded-runner/run/attempt.ts | 3 + .../run/failover-policy.test.ts | 41 +++++ .../pi-embedded-runner/run/failover-policy.ts | 14 ++ src/agents/pi-embedded-runner/run/types.ts | 2 + 10 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 extensions/active-memory/config.test.ts diff --git a/extensions/active-memory/config.test.ts b/extensions/active-memory/config.test.ts new file mode 100644 index 00000000000..1326ba13e68 --- /dev/null +++ b/extensions/active-memory/config.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validateJsonSchemaValue } from "../../src/plugins/schema-validator.js"; + +const manifest = JSON.parse( + fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"), +) as { configSchema: Record }; + +describe("active-memory manifest config schema", () => { + it("accepts modelFallback for CLI and config.patch flows", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "active-memory.manifest.model-fallback", + value: { + enabled: true, + agents: ["main"], + modelFallback: "google/gemini-3-flash", + modelFallbackPolicy: "resolved-only", + }, + }); + + expect(result.ok).toBe(true); + }); +}); diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 75d3f73ad9d..13300b67951 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -97,7 +97,15 @@ describe("active-memory plugin", () => { agents: ["main"], logging: true, }; - api.config = {}; + api.config = { + agents: { + defaults: { + model: { + primary: "github-copilot/gpt-5.4-mini", + }, + }, + }, + }; hoisted.sessionStore["agent:main:main"] = { sessionId: "s-main", updatedAt: 0, @@ -381,7 +389,16 @@ describe("active-memory plugin", () => { }); it("treats non-default main session keys as direct chats", async () => { - api.config = { session: { mainKey: "home" } }; + api.config = { + agents: { + defaults: { + model: { + primary: "github-copilot/gpt-5.4-mini", + }, + }, + }, + session: { mainKey: "home" }, + }; const result = await hooks.before_prompt_build( { prompt: "what wings should i order?", messages: [] }, @@ -454,6 +471,8 @@ describe("active-memory plugin", () => { expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({ provider: "github-copilot", model: "gpt-5.4-mini", + messageChannel: "webchat", + messageProvider: "webchat", sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/), }); }); @@ -730,7 +749,8 @@ describe("active-memory plugin", () => { }); }); - it("can disable default remote model fallback", async () => { + it("skips recall when no model or explicit fallback resolves", async () => { + api.config = {}; api.pluginConfig = { agents: ["main"], modelFallbackPolicy: "resolved-only", @@ -751,6 +771,53 @@ describe("active-memory plugin", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); + it("uses config.modelFallback before the built-in default fallback", async () => { + api.config = {}; + api.pluginConfig = { + agents: ["main"], + modelFallback: "google/gemini-3-flash", + modelFallbackPolicy: "resolved-only", + }; + await plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { prompt: "what wings should i order? custom fallback", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:custom-fallback", + messageProvider: "webchat", + }, + ); + + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({ + provider: "google", + model: "gemini-3-flash-preview", + }); + }); + + it("does not use a built-in fallback model even when default-remote is configured", async () => { + api.config = {}; + api.pluginConfig = { + agents: ["main"], + modelFallbackPolicy: "default-remote", + }; + await plugin.register(api as unknown as OpenClawPluginApi); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? built-in fallback", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:built-in-fallback", + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + it("persists a readable debug summary alongside the status line", async () => { const sessionKey = "agent:main:debug"; hoisted.sessionStore[sessionKey] = { @@ -946,10 +1013,43 @@ describe("active-memory plugin", () => { expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false); }); + it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => { + api.pluginConfig = { + agents: ["main"], + timeoutMs: 250, + logging: true, + }; + await plugin.register(api as unknown as OpenClawPluginApi); + runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => { + await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 25)); + return { + payloads: [{ text: "late timeout payload that should never become memory context" }], + meta: { aborted: true }, + }; + }); + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order? late payload timeout", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:late-timeout-payload", + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + const infoLines = vi + .mocked(api.logger.info) + .mock.calls.map((call: unknown[]) => String(call[0])); + expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true); + }); + it("uses a canonical agent session key when only sessionId is available", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", updatedAt: 25, + channel: "telegram", }; await hooks.before_prompt_build( @@ -965,6 +1065,10 @@ describe("active-memory plugin", () => { expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch( /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/, ); + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({ + messageChannel: "telegram", + messageProvider: "webchat", + }); expect(hoisted.sessionStore["agent:main:telegram:direct:12345"]?.pluginDebugEntries).toEqual([ { pluginId: "active-memory", diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 6c1dc171b53..9d2d62f079e 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -25,7 +25,6 @@ const DEFAULT_RECENT_USER_CHARS = 220; const DEFAULT_RECENT_ASSISTANT_CHARS = 180; const DEFAULT_CACHE_TTL_MS = 15_000; const DEFAULT_MAX_CACHE_ENTRIES = 1000; -const DEFAULT_MODEL_REF = "github-copilot/gpt-5.4-mini"; const DEFAULT_QUERY_MODE = "recent" as const; const DEFAULT_TRANSCRIPT_DIR = "active-memory"; const TOGGLE_STATE_FILE = "session-toggles.json"; @@ -58,6 +57,7 @@ type ActiveRecallPluginConfig = { enabled?: boolean; agents?: string[]; model?: string; + modelFallback?: string; modelFallbackPolicy?: "default-remote" | "resolved-only"; allowedChatTypes?: Array<"direct" | "group" | "channel">; thinking?: ActiveMemoryThinkingLevel; @@ -87,6 +87,7 @@ type ResolvedActiveRecallPluginConfig = { enabled: boolean; agents: string[]; model?: string; + modelFallback?: string; modelFallbackPolicy: "default-remote" | "resolved-only"; allowedChatTypes: Array<"direct" | "group" | "channel">; thinking: ActiveMemoryThinkingLevel; @@ -314,6 +315,65 @@ function resolveCanonicalSessionKeyFromSessionId(params: { } } +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolveRecallRunChannelContext(params: { + api: OpenClawPluginApi; + agentId: string; + sessionKey?: string; + sessionId?: string; + messageProvider?: string; + channelId?: string; +}): { + messageChannel?: string; + messageProvider?: string; +} { + const explicitChannel = normalizeOptionalString(params.channelId); + const explicitProvider = normalizeOptionalString(params.messageProvider); + const resolvedSessionKey = + normalizeOptionalString(params.sessionKey) ?? + resolveCanonicalSessionKeyFromSessionId({ + api: params.api, + agentId: params.agentId, + sessionId: params.sessionId, + }); + if (!resolvedSessionKey) { + return { + messageChannel: explicitChannel ?? explicitProvider, + messageProvider: explicitProvider ?? explicitChannel, + }; + } + + try { + const storePath = params.api.runtime.agent.session.resolveStorePath( + params.api.config.session?.store, + { + agentId: params.agentId, + }, + ); + const store = params.api.runtime.agent.session.loadSessionStore(storePath); + const sessionEntry = resolveSessionStoreEntry({ + store, + sessionKey: resolvedSessionKey, + }).existing; + const entryChannel = + normalizeOptionalString(sessionEntry?.lastChannel) ?? + normalizeOptionalString(sessionEntry?.channel) ?? + normalizeOptionalString(sessionEntry?.origin?.provider); + return { + messageChannel: explicitChannel ?? entryChannel ?? explicitProvider, + messageProvider: explicitProvider ?? explicitChannel ?? entryChannel, + }; + } catch { + return { + messageChannel: explicitChannel ?? explicitProvider, + messageProvider: explicitProvider ?? explicitChannel, + }; + } +} + function resolveToggleStatePath(api: OpenClawPluginApi): string { return path.join( api.runtime.state.resolveStateDir(), @@ -498,6 +558,10 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi ? raw.agents.map((agentId) => agentId.trim()).filter(Boolean) : [], model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined, + modelFallback: + typeof raw.modelFallback === "string" && raw.modelFallback.trim() + ? raw.modelFallback.trim() + : undefined, modelFallbackPolicy: raw.modelFallbackPolicy === "resolved-only" ? "resolved-only" : "default-remote", allowedChatTypes: allowedChatTypes.length > 0 ? allowedChatTypes : ["direct"], @@ -1136,6 +1200,15 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] { return turns; } +function parseModelCandidate(modelRef: string | undefined) { + if (!modelRef) { + return undefined; + } + return ( + parseModelRef(modelRef, DEFAULT_PROVIDER) ?? { provider: DEFAULT_PROVIDER, model: modelRef } + ); +} + function getModelRef( api: OpenClawPluginApi, agentId: string, @@ -1144,31 +1217,35 @@ function getModelRef( modelProviderId?: string; modelId?: string; }, -) { +): { + modelRef?: { + provider: string; + model: string; + }; + source: "plugin-model" | "session-model" | "agent-primary" | "config-fallback" | "none"; +} { const currentRunModel = ctx?.modelProviderId && ctx?.modelId ? `${ctx.modelProviderId}/${ctx.modelId}` : undefined; const agentPrimaryModel = resolveAgentEffectiveModelPrimary(api.config, agentId); - const configured = - config.model || - currentRunModel || - agentPrimaryModel || - (config.modelFallbackPolicy === "default-remote" ? DEFAULT_MODEL_REF : undefined); - if (!configured) { - return undefined; - } - const parsed = parseModelRef(configured, DEFAULT_PROVIDER); - if (parsed) { - return parsed; - } - const parsedAgentPrimary = agentPrimaryModel - ? parseModelRef(agentPrimaryModel, DEFAULT_PROVIDER) - : undefined; - return ( - parsedAgentPrimary ?? { - provider: DEFAULT_PROVIDER, - model: configured, + const candidates: Array<{ + source: "plugin-model" | "session-model" | "agent-primary" | "config-fallback"; + value?: string; + }> = [ + { source: "plugin-model", value: config.model }, + { source: "session-model", value: currentRunModel }, + { source: "agent-primary", value: agentPrimaryModel }, + { source: "config-fallback", value: config.modelFallback }, + ]; + for (const candidate of candidates) { + const parsed = parseModelCandidate(candidate.value); + if (parsed) { + return { + modelRef: parsed, + source: candidate.source, + }; } - ); + } + return { source: "none" }; } async function runRecallSubagent(params: { @@ -1177,6 +1254,8 @@ async function runRecallSubagent(params: { agentId: string; sessionKey?: string; sessionId?: string; + messageProvider?: string; + channelId?: string; query: string; currentModelProviderId?: string; currentModelId?: string; @@ -1184,7 +1263,7 @@ async function runRecallSubagent(params: { }): Promise<{ rawReply: string; transcriptPath?: string }> { const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId); const agentDir = resolveAgentDir(params.api.config, params.agentId); - const modelRef = getModelRef(params.api, params.agentId, params.config, { + const { modelRef } = getModelRef(params.api, params.agentId, params.config, { modelProviderId: params.currentModelProviderId, modelId: params.currentModelId, }); @@ -1228,12 +1307,22 @@ async function runRecallSubagent(params: { config: params.config, query: params.query, }); + const { messageChannel, messageProvider } = resolveRecallRunChannelContext({ + api: params.api, + agentId: params.agentId, + sessionKey: parentSessionKey, + sessionId: params.sessionId, + messageProvider: params.messageProvider, + channelId: params.channelId, + }); try { const result = await params.api.runtime.agent.runEmbeddedPiAgent({ sessionId: subagentSessionId, sessionKey: subagentSessionKey, agentId: params.agentId, + messageChannel, + messageProvider, sessionFile, workspaceDir, agentDir, @@ -1253,6 +1342,18 @@ async function runRecallSubagent(params: { silentExpected: true, abortSignal: params.abortSignal, }); + if (params.abortSignal?.aborted) { + const reason = params.abortSignal.reason; + if (reason instanceof Error) { + throw reason; + } + const abortErr = + reason !== undefined + ? new Error("Operation aborted", { cause: reason }) + : new Error("Operation aborted"); + abortErr.name = "AbortError"; + throw abortErr; + } const rawReply = (result.payloads ?? []) .map((payload) => payload.text?.trim() ?? "") .filter(Boolean) @@ -1275,6 +1376,8 @@ async function maybeResolveActiveRecall(params: { agentId: string; sessionKey?: string; sessionId?: string; + messageProvider?: string; + channelId?: string; query: string; currentModelProviderId?: string; currentModelId?: string; @@ -1539,6 +1642,8 @@ export default definePluginEntry({ agentId: effectiveAgentId, sessionKey: resolvedSessionKey, sessionId: ctx.sessionId, + messageProvider: ctx.messageProvider, + channelId: ctx.channelId, query, currentModelProviderId: ctx.modelProviderId, currentModelId: ctx.modelId, diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index d7216331fed..34fd1c51f2c 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -12,6 +12,7 @@ "items": { "type": "string" } }, "model": { "type": "string" }, + "modelFallback": { "type": "string" }, "modelFallbackPolicy": { "type": "string", "enum": ["default-remote", "resolved-only"] @@ -69,9 +70,13 @@ "label": "Memory Model", "help": "Provider/model used for the blocking memory sub-agent." }, + "modelFallback": { + "label": "Fallback Memory Model", + "help": "Optional provider/model to use if no explicit plugin model, session model, or agent primary model resolves." + }, "modelFallbackPolicy": { "label": "Model Fallback Policy", - "help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available." + "help": "Deprecated compatibility field. Active Memory no longer uses a built-in fallback model; set modelFallback explicitly if you want a fallback." }, "allowedChatTypes": { "label": "Allowed Chat Types", diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 492db8882ee..1879c1b74ce 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -717,6 +717,7 @@ export async function runEmbeddedPiAgent( const { aborted, + externalAbort, promptError, promptErrorSource, preflightRecovery, @@ -1275,6 +1276,7 @@ export async function runEmbeddedPiAgent( let promptFailoverDecision = resolveRunFailoverDecision({ stage: "prompt", aborted, + externalAbort, fallbackConfigured, failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, @@ -1296,6 +1298,7 @@ export async function runEmbeddedPiAgent( promptFailoverDecision = resolveRunFailoverDecision({ stage: "prompt", aborted, + externalAbort, fallbackConfigured, failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, @@ -1415,6 +1418,7 @@ export async function runEmbeddedPiAgent( const assistantFailoverDecision = resolveRunFailoverDecision({ stage: "assistant", aborted, + externalAbort, fallbackConfigured, failoverFailure, failoverReason: assistantFailoverReason, @@ -1425,6 +1429,7 @@ export async function runEmbeddedPiAgent( const assistantFailoverOutcome = await handleAssistantFailover({ initialDecision: assistantFailoverDecision, aborted, + externalAbort, fallbackConfigured, failoverFailure, failoverReason: assistantFailoverReason, diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.ts b/src/agents/pi-embedded-runner/run/assistant-failover.ts index bebfc1c82a1..be8460f4ce3 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.ts @@ -35,6 +35,7 @@ type AssistantFailoverOutcome = export async function handleAssistantFailover(params: { initialDecision: AssistantFailoverDecision; aborted: boolean; + externalAbort: boolean; fallbackConfigured: boolean; failoverFailure: boolean; failoverReason: FailoverReason | null; @@ -169,6 +170,7 @@ export async function handleAssistantFailover(params: { decision = resolveRunFailoverDecision({ stage: "assistant", aborted: params.aborted, + externalAbort: params.externalAbort, fallbackConfigured: params.fallbackConfigured, failoverFailure: params.failoverFailure, failoverReason: params.failoverReason, @@ -219,7 +221,7 @@ export async function handleAssistantFailover(params: { } if (decision.action === "surface_error") { - if (params.idleTimedOut && params.allowSameModelIdleTimeoutRetry) { + if (!params.externalAbort && params.idleTimedOut && params.allowSameModelIdleTimeoutRetry) { return sameModelIdleTimeoutRetry(); } params.logAssistantFailoverDecision("surface_error"); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 032595ba5de..ddb011762c4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1367,6 +1367,7 @@ export async function runEmbeddedAttempt( } let aborted = Boolean(params.abortSignal?.aborted); + let externalAbort = false; let yieldAborted = false; let timedOut = false; let idleTimedOut = false; @@ -1584,6 +1585,7 @@ export async function runEmbeddedAttempt( let messagesSnapshot: AgentMessage[] = []; let sessionIdUsed = activeSession.sessionId; const onAbort = () => { + externalAbort = true; const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined; const timeout = reason ? isTimeoutError(reason) : false; if ( @@ -2338,6 +2340,7 @@ export async function runEmbeddedAttempt( itemLifecycle: getItemLifecycle(), setTerminalLifecycleMeta, aborted, + externalAbort, timedOut, idleTimedOut, timedOutDuringCompaction, diff --git a/src/agents/pi-embedded-runner/run/failover-policy.test.ts b/src/agents/pi-embedded-runner/run/failover-policy.test.ts index 47c4b13fd8e..c4081c5789a 100644 --- a/src/agents/pi-embedded-runner/run/failover-policy.test.ts +++ b/src/agents/pi-embedded-runner/run/failover-policy.test.ts @@ -32,6 +32,7 @@ describe("resolveRunFailoverDecision", () => { resolveRunFailoverDecision({ stage: "prompt", aborted: false, + externalAbort: false, fallbackConfigured: true, failoverFailure: true, failoverReason: "rate_limit", @@ -48,6 +49,7 @@ describe("resolveRunFailoverDecision", () => { resolveRunFailoverDecision({ stage: "prompt", aborted: false, + externalAbort: false, fallbackConfigured: true, failoverFailure: true, failoverReason: "rate_limit", @@ -64,6 +66,7 @@ describe("resolveRunFailoverDecision", () => { resolveRunFailoverDecision({ stage: "assistant", aborted: false, + externalAbort: false, fallbackConfigured: true, failoverFailure: false, failoverReason: "rate_limit", @@ -82,6 +85,7 @@ describe("resolveRunFailoverDecision", () => { resolveRunFailoverDecision({ stage: "assistant", aborted: false, + externalAbort: false, fallbackConfigured: true, failoverFailure: false, failoverReason: "rate_limit", @@ -100,6 +104,7 @@ describe("resolveRunFailoverDecision", () => { resolveRunFailoverDecision({ stage: "assistant", aborted: false, + externalAbort: false, fallbackConfigured: true, failoverFailure: false, failoverReason: null, @@ -111,6 +116,42 @@ describe("resolveRunFailoverDecision", () => { action: "continue_normal", }); }); + + it("does not model-fallback prompt failures after an external abort", () => { + expect( + resolveRunFailoverDecision({ + stage: "prompt", + aborted: true, + externalAbort: true, + fallbackConfigured: true, + failoverFailure: true, + failoverReason: "timeout", + profileRotated: false, + }), + ).toEqual({ + action: "surface_error", + reason: "timeout", + }); + }); + + it("does not rotate or fallback assistant timeouts after an external abort", () => { + expect( + resolveRunFailoverDecision({ + stage: "assistant", + aborted: true, + externalAbort: true, + fallbackConfigured: true, + failoverFailure: false, + failoverReason: null, + timedOut: true, + timedOutDuringCompaction: false, + profileRotated: false, + }), + ).toEqual({ + action: "surface_error", + reason: null, + }); + }); }); describe("mergeRetryFailoverReason", () => { diff --git a/src/agents/pi-embedded-runner/run/failover-policy.ts b/src/agents/pi-embedded-runner/run/failover-policy.ts index 0b8c8233615..1053fd69173 100644 --- a/src/agents/pi-embedded-runner/run/failover-policy.ts +++ b/src/agents/pi-embedded-runner/run/failover-policy.ts @@ -47,6 +47,7 @@ type RetryLimitDecisionParams = { type PromptDecisionParams = { stage: "prompt"; aborted: boolean; + externalAbort: boolean; fallbackConfigured: boolean; failoverFailure: boolean; failoverReason: FailoverReason | null; @@ -56,6 +57,7 @@ type PromptDecisionParams = { type AssistantDecisionParams = { stage: "assistant"; aborted: boolean; + externalAbort: boolean; fallbackConfigured: boolean; failoverFailure: boolean; failoverReason: FailoverReason | null; @@ -120,6 +122,12 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R } if (params.stage === "prompt") { + if (params.externalAbort) { + return { + action: "surface_error", + reason: params.failoverReason, + }; + } if (!params.profileRotated && shouldRotatePrompt(params)) { return { action: "rotate_profile", @@ -138,6 +146,12 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R }; } + if (params.externalAbort) { + return { + action: "surface_error", + reason: params.failoverReason, + }; + } const assistantShouldRotate = shouldRotateAssistant(params); if (!params.profileRotated && assistantShouldRotate) { return { diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index c69b606bcf2..8429f0dc82d 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -41,6 +41,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { export type EmbeddedRunAttemptResult = { aborted: boolean; + /** True when the abort originated from the caller-provided abortSignal. */ + externalAbort: boolean; timedOut: boolean; /** True when the no-response LLM idle watchdog caused the timeout. */ idleTimedOut: boolean;