diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 71a97ee62d9f..ccfd2d36c397 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,7 +53,7 @@ {"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"6fdbebb5c0470d28dd5089084531421c4806ebd35ae3426851f6f507f9eb1eda","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} +{"closureHash":"6184663b7bda9bc246ccb9ec7457a0f594f11941b155f308511e239db901c916","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} @@ -1097,7 +1097,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} -{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} +{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} @@ -1226,7 +1226,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} -{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} +{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} @@ -2354,7 +2354,7 @@ {"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"9d503d05e408ddd1a1c373e3c4485b1f9acff4c240fdc241840a2cfa8b55c3a7","declaration":"export function runChannelInboundEvent(params: RunChannelTurnParams): Promise>;\nexport function runChannelInboundEvent(params: ChannelInboundEventRunnerParams): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply(params: PreparedChannelTurn): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} -{"closureHash":"e7b2af710d0e415e78cf19c2971e952a076d09f9f0ca00fe6d2cd49f3aabef70","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} +{"closureHash":"7384eb5068abd88b6e1d33cdcf583cfb4d1e8d0fb98beaf79b958d104977ca39","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} {"closureHash":"278dc8a410230012ddbc3552ec621a224e6f7c5dd4cd3904c943ac98dd4259e4","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} @@ -3189,19 +3189,19 @@ {"closureHash":"50394942e0a454c742e80a4f3c8739c80258b387ade498eb5aed66df887108be","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"863915a7e74c53eac4c897d3b5a51de85cfc85918db0bca4a36cf6daeeed63d9","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"28efffa08c777fe436dcbe325179c9382df7067408c7bcf6c61ec41ce57b6b6e","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9cb63eefd2e5a70aea960bf17fcf570ef02a2d7b5afd5ad9451802964c653fcf","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"8783d96ff20b445457add8369c6ed8156038e7edb8fdc31fabeb92c8bcf5a9a2","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"681d30651dd7d6ea197c4c37718c2022da017ea6f538aad4a5399682d707ec78","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin; createdAt: string; }): MeetingSessionRecord;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"93c4bb70429a2739151e6f900e67bed81f3cd8f597811220e790341edb0d6008","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"4fd4507ee880b0ef6d61d2978aeaa2367b1c8d87e6060ff17ae6125884713fac","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"ad7c8334bc5d6c66a3ecfc0e4ed2cb33170cb432cdf593b6ad68d238347c0ba6","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"612e8c45b04b7b8da15d6f1d56fa85a50b6e6f2b5c7792f02e04dd1fd1b135df","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"3b53c387db13f3dc8e599bb14f98b6a11ae44607afe97c951bef3ec4aa6a8c7b","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} @@ -3212,8 +3212,8 @@ {"closureHash":"56874c5b2ced5af647b1dff296c931f0d8b85b1318e98c741aa88b58a9c5c606","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"3806e9a72af2d7de40e65e979771064b8142ef300826cc335dc6592999eeecef","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"e946e36e2de5f85193a9940159cc43453cb9e94bec37e51edf2d3690359e396c","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"02dadbf91f221d8471e27a1b1744de46f291082e1bbfc79266c32e3c9b9a6e27","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"c48350de1750bc99116c987a24f090435f745a06c90b855bd536d0edaad8c0d5","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3245,7 +3245,7 @@ {"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles = MeetingSessionRuntimeHandles;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages = MeetingSessionRuntimeMessages;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"05e0c2d4024d954e9d0c5b993a2eef1ec19f0ed76842a1e62489713121a51f0c","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} +{"closureHash":"736c01afcaad8e9c228ccfe8728a7323edf03583753cdf99aeef2cbd4646304b","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3259,9 +3259,9 @@ {"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"52f2a358bd777b737c9ff079edf5887b5d6b387ea9bb65acd98733f0fc95ed43","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} +{"closureHash":"26f7c1ed34ea23dbf5e9f4e65f65c78c12cbae1864208a5f92c305da4c6f086b","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} {"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise;\n writeOutput(audio: Buffer): Promise;\n clearOutput(): Promise;\n dispose(): Promise;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} -{"closureHash":"368b6b83032d8c166d11282f85ce9b88c012cb8e13a9dbcf187175573aacc826","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} +{"closureHash":"ddae4750513c758c0272f195bb0279ac172a00bfe05584dd6afea15aa379576d","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} {"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} @@ -3655,10 +3655,10 @@ {"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"} {"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"} {"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"1b91fb602c859027c6c32f35b1d6db992182644f0b31d5dc723c4dc68b4ff341","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"7b8b2d050f3124b21a3ce4138f8621bc9148fb38cbb485906e2e17126a3d217e","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"fe7c0743b550b192b24fe084c30ddf85a36a0e0331894802170ab4e1bf099586","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"ea60a3329d0c562308bef80011e1e3169147eadb8c4fd98343fc71c8eb9cd827","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"b54c329dcf145df4c62be2fcb763a08b692edfa7bc4091d4a059454abd371eba","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"4d40b7ef2d1cc95ccfc5eea75db17eef20b8356b1df02df5c157e68fc79bafe2","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"388729ca5432166998c770d2dc5f671c53ba6951c8b227d7785d838e99c340a1","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a5666fc15fd60e0168bd43305ecd02a27ef6415b7f2fa9853c7226589e1ededd","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} {"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"} {"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} {"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} diff --git a/extensions/qa-channel/src/bus-client.ts b/extensions/qa-channel/src/bus-client.ts index 3b9617e8ab31..3eb6e49287de 100644 --- a/extensions/qa-channel/src/bus-client.ts +++ b/extensions/qa-channel/src/bus-client.ts @@ -190,6 +190,7 @@ export async function sendQaBusMessage(params: { accountId: string; to: string; text: string; + isError?: boolean; senderId?: string; senderName?: string; threadId?: string; diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 47c1eed2b462..6b9a0fc09e68 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -316,7 +316,12 @@ describe("qa-channel plugin", () => { kind === "payload" ? await adapter.send!.payload!({ ...context, - payload: { text: context.text, mediaUrl: mediaPath, mediaUrls: [mediaPath] }, + payload: { + text: context.text, + mediaUrl: mediaPath, + mediaUrls: [mediaPath], + isError: true, + }, }) : await adapter.send!.media!(context); expect(result.receipt.parts[0]).toMatchObject({ @@ -324,6 +329,9 @@ describe("qa-channel plugin", () => { replyToId: "parent-1", threadId: "thread-1", }); + if (kind === "payload") { + expect(harness.state.getSnapshot().messages.at(-1)?.isError).toBe(true); + } }; await verifyChannelMessageAdapterCapabilityProofs({ diff --git a/extensions/qa-channel/src/channel.ts b/extensions/qa-channel/src/channel.ts index 9827989d826d..e2b9065463b0 100644 --- a/extensions/qa-channel/src/channel.ts +++ b/extensions/qa-channel/src/channel.ts @@ -68,6 +68,7 @@ async function sendQaChannelMessagePayload(ctx: QaChannelPayloadSendContext) { accountId: ctx.accountId, to: ctx.to, text, + isError: ctx.payload.isError, threadId: ctx.threadId, replyToId: ctx.replyToId, }; @@ -224,6 +225,7 @@ export const qaChannelPlugin: ChannelPlugin = createCh outbound: { base: { deliveryMode: "direct", + sendTextOnlyErrorPayloads: true, sendPayload: async (ctx) => { const result = await sendQaChannelMessagePayload(ctx); return { channel: QA_CHANNEL_ID, messageId: result.messageId }; diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index 5146b6bc1065..ff441283ebdd 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -207,7 +207,7 @@ function createQaReplyPreview(params: { currentText = ""; }; - const sendDurable = async (text: string) => { + const sendDurable = async (text: string, isError?: boolean) => { if (!text.trim()) { return; } @@ -217,6 +217,7 @@ function createQaReplyPreview(params: { accountId: params.account.accountId, to: params.target, text, + isError, senderId: params.account.botUserId, senderName: params.account.botDisplayName, threadId: params.inbound.threadId, @@ -229,8 +230,15 @@ function createQaReplyPreview(params: { return { clear, - async deliver(text: string, kind: string) { + async deliver(text: string, kind: string, isError?: boolean) { await pending; + if (isError === true) { + // Preview edits cannot add the typed failure marker. Replace any preview + // with one durable marked message so QA Lab cannot accept it as success. + await clear(); + await sendDurable(text, true); + return; + } // Core may close a streamed block with an identical final payload. // The block is already durable, so posting the final again duplicates the reply. if ( @@ -419,7 +427,12 @@ export async function handleQaInbound(params: { deliver: async (payload, info) => { const reply = payload && typeof payload === "object" - ? (payload as { text?: string; mediaUrl?: string; mediaUrls?: string[] }) + ? (payload as { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + isError?: boolean; + }) : undefined; const text = reply?.text ?? ""; const mediaUrls = Array.from( @@ -444,6 +457,7 @@ export async function handleQaInbound(params: { accountId: params.account.accountId, to: target, text, + isError: reply?.isError, mediaUrls, mediaLocalRoots: getAgentScopedMediaLocalRoots( params.config as OpenClawConfig, @@ -457,7 +471,7 @@ export async function handleQaInbound(params: { if (!text.trim()) { return; } - await preview.deliver(text, info?.kind ?? "final"); + await preview.deliver(text, info?.kind ?? "final", reply?.isError); }, onError: (error) => { void preview.clear().catch((clearError: unknown) => { diff --git a/extensions/qa-channel/src/outbound.ts b/extensions/qa-channel/src/outbound.ts index a1563ba7d148..ccfc0d733607 100644 --- a/extensions/qa-channel/src/outbound.ts +++ b/extensions/qa-channel/src/outbound.ts @@ -15,6 +15,7 @@ type QaChannelTextSendParams = { accountId?: string | null; to: string; text: string; + isError?: boolean; threadId?: string | number | null; replyToId?: string | number | null; attachments?: QaBusAttachment[]; @@ -39,6 +40,7 @@ export async function sendQaChannelText(params: QaChannelTextSendParams) { threadId: resolved.threadId, }), text: params.text, + isError: params.isError, senderId: account.botUserId, senderName: account.botDisplayName, threadId: resolved.threadId, diff --git a/extensions/qa-lab/src/bus-state.ts b/extensions/qa-lab/src/bus-state.ts index 3d11a29e275c..f92caee11701 100644 --- a/extensions/qa-lab/src/bus-state.ts +++ b/extensions/qa-lab/src/bus-state.ts @@ -140,6 +140,7 @@ export function createQaBusState() { senderId: string; senderName?: string; text: string; + isError?: boolean; timestamp?: number; threadId?: string; threadTitle?: string; @@ -174,6 +175,7 @@ export function createQaBusState() { senderId: params.senderId, senderName: params.senderName, text: params.text, + ...(params.isError === true ? { isError: true } : {}), timestamp: params.timestamp ?? Date.now(), threadId: params.threadId, threadTitle: params.threadTitle, @@ -246,6 +248,7 @@ export function createQaBusState() { senderId: input.senderId?.trim() || DEFAULT_BOT_ID, senderName: input.senderName?.trim() || DEFAULT_BOT_NAME, text: input.text, + isError: input.isError, timestamp: input.timestamp, threadId: input.threadId ?? threadId, replyToId: input.replyToId, diff --git a/extensions/qa-lab/src/harness-runtime.integration.test.ts b/extensions/qa-lab/src/harness-runtime.integration.test.ts index 0c73a06e66fb..0f87923213de 100644 --- a/extensions/qa-lab/src/harness-runtime.integration.test.ts +++ b/extensions/qa-lab/src/harness-runtime.integration.test.ts @@ -1,51 +1,78 @@ // Qa Lab integration tests cover the real QA Channel runtime contract. import { qaChannelPlugin, setQaChannelRuntime } from "@openclaw/qa-channel/api.js"; -import { describe, expect, it, vi } from "vitest"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { startQaBusServer } from "./bus-server.js"; import { createQaBusState } from "./bus-state.js"; import { createQaRunnerRuntime } from "./harness-runtime.js"; -import { createQaChannelGatewayConfig } from "./qa-channel-transport.js"; +import { createQaChannelGatewayConfig, createQaChannelTransport } from "./qa-channel-transport.js"; + +async function startQaRuntimeIntegration(runtime = createQaRunnerRuntime()) { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "qa-channel", plugin: qaChannelPlugin, source: "test" }]), + ); + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + setQaChannelRuntime(runtime); + const config = createQaChannelGatewayConfig({ baseUrl: bus.baseUrl }); + const account = qaChannelPlugin.config.resolveAccount(config, "default"); + const abort = new AbortController(); + const startAccount = qaChannelPlugin.gateway?.startAccount; + if (!startAccount) { + throw new Error("QA Channel gateway is unavailable"); + } + const gatewayTask = startAccount({ + accountId: account.accountId, + account, + cfg: config, + runtime: { + log: () => undefined, + error: () => undefined, + exit: () => undefined, + }, + abortSignal: abort.signal, + log: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + }, + getStatus: () => ({ + accountId: account.accountId, + configured: true, + enabled: true, + running: true, + }), + setStatus: () => undefined, + }); + return { + state, + gatewayTask, + async stop() { + abort.abort(); + try { + await gatewayTask; + } finally { + await bus.stop(); + } + }, + }; +} + +afterEach(() => { + resetPluginRuntimeStateForTest(); +}); describe("QA runner runtime integration", () => { it("dispatches a QA Channel inbound turn through the embedded runner", async () => { - const state = createQaBusState(); - const bus = await startQaBusServer({ state }); - const runtime = createQaRunnerRuntime(); - setQaChannelRuntime(runtime); - const config = createQaChannelGatewayConfig({ baseUrl: bus.baseUrl }); - const account = qaChannelPlugin.config.resolveAccount(config, "default"); - const abort = new AbortController(); - const startAccount = qaChannelPlugin.gateway?.startAccount; - if (!startAccount) { - throw new Error("QA Channel gateway is unavailable"); - } - const gatewayTask = startAccount({ - accountId: account.accountId, - account, - cfg: config, - runtime: { - log: () => undefined, - error: () => undefined, - exit: () => undefined, - }, - abortSignal: abort.signal, - log: { - info: () => undefined, - warn: () => undefined, - error: () => undefined, - debug: () => undefined, - }, - getStatus: () => ({ - accountId: account.accountId, - configured: true, - enabled: true, - running: true, - }), - setStatus: () => undefined, - }); + const harness = await startQaRuntimeIntegration(); try { - state.addInboundMessage({ + harness.state.addInboundMessage({ accountId: "default", conversation: { kind: "direct", id: "alice" }, senderId: "alice", @@ -56,23 +83,78 @@ describe("QA runner runtime integration", () => { await Promise.race([ vi.waitFor( () => { - expect(state.getSnapshot().messages).toContainEqual( + expect(harness.state.getSnapshot().messages).toContainEqual( expect.objectContaining({ direction: "outbound", text: "qa-echo: ping" }), ); }, { interval: 25, timeout: 2_000 }, ), - gatewayTask.then(() => { + harness.gatewayTask.then(() => { throw new Error("QA Channel gateway stopped before delivering the turn"); }), ]); } finally { - abort.abort(); - try { - await gatewayTask; - } finally { - await bus.stop(); + await harness.stop(); + } + }); + + it("marks and detects text-only errors across QA delivery paths", async () => { + const failureText = "Text-only QA failure from the embedded runner."; + const runtime = createQaRunnerRuntime(); + const dispatchFailure = async ( + params: Parameters[0], + ) => { + if (!("deliver" in params.delivery) || typeof params.delivery.deliver !== "function") { + throw new Error("QA failure fixture requires core-managed delivery"); } + await params.delivery.deliver({ text: failureText, isError: true }, { kind: "final" }); + return { + admission: params.admission ?? { kind: "dispatch" as const }, + dispatched: true, + ctxPayload: params.ctxPayload, + routeSessionKey: params.route.sessionKey, + dispatchResult: { + queuedFinal: false, + counts: { tool: 0, block: 0, final: 1 }, + }, + }; + }; + runtime.channel.inbound.dispatch = dispatchFailure as typeof runtime.channel.inbound.dispatch; + const harness = await startQaRuntimeIntegration(runtime); + const transport = createQaChannelTransport(harness.state); + let sent = false; + + try { + expect(qaChannelPlugin.outbound?.sendTextOnlyErrorPayloads).toBe(true); + await Promise.race([ + expect( + transport.waitForCondition( + async () => { + if (!sent) { + sent = true; + harness.state.addInboundMessage({ + accountId: "default", + conversation: { kind: "direct", id: "alice" }, + senderId: "alice", + senderName: "Alice", + text: "trigger a text-only failure", + }); + } + return undefined; + }, + 2_000, + 25, + ), + ).rejects.toThrow(failureText), + harness.gatewayTask.then(() => { + throw new Error("QA Channel gateway stopped before delivering the failure"); + }), + ]); + expect(harness.state.getSnapshot().messages).toContainEqual( + expect.objectContaining({ direction: "outbound", text: failureText, isError: true }), + ); + } finally { + await harness.stop(); } }); }); diff --git a/extensions/qa-lab/src/qa-channel-transport.test.ts b/extensions/qa-lab/src/qa-channel-transport.test.ts index 2a1d0d3ba89f..2fb7ed699eda 100644 --- a/extensions/qa-lab/src/qa-channel-transport.test.ts +++ b/extensions/qa-lab/src/qa-channel-transport.test.ts @@ -195,6 +195,7 @@ describe("qa channel transport", () => { state.addOutboundMessage({ accountId: "other", to: "dm:alice", + isError: true, text: "⚠️ agent failed before reply: foreign account failure", }); const expected = state.addOutboundMessage({ @@ -239,6 +240,7 @@ describe("qa channel transport", () => { state.addOutboundMessage({ accountId: "other", to: "dm:alice", + isError: true, text: "⚠️ agent failed before reply: foreign account failure", }); return "owned condition completed"; @@ -281,6 +283,7 @@ describe("qa channel transport", () => { await transport.state.addOutboundMessage({ accountId: "default", to: "dm:qa-operator", + isError: true, text: "⚠️ agent failed before reply: synthetic failure for wait helper", }); } @@ -298,6 +301,7 @@ describe("qa channel transport", () => { await transport.state.addOutboundMessage({ accountId: "default", to: "dm:qa-operator", + isError: true, text: "⚠️ agent failed before reply: stale failure should not leak", }); diff --git a/extensions/qa-lab/src/qa-transport.test.ts b/extensions/qa-lab/src/qa-transport.test.ts index 2eb95e07941f..ee58786237a1 100644 --- a/extensions/qa-lab/src/qa-transport.test.ts +++ b/extensions/qa-lab/src/qa-transport.test.ts @@ -231,6 +231,7 @@ describe("waitForQaTransportOutboundSequence", () => { state.addOutboundMessage({ accountId: "default", to: "dm:alice", + isError: true, text: "⚠️ agent failed before reply: provider rejected this request", }); @@ -266,6 +267,7 @@ describe("waitForQaTransportOutboundSequence", () => { state.addOutboundMessage({ accountId: "default", to: "dm:alice", + isError: true, text: "⚠️ agent failed before reply: stale failure", }); const sinceCursor = state.getSnapshot().cursor; @@ -273,6 +275,7 @@ describe("waitForQaTransportOutboundSequence", () => { state.addOutboundMessage({ accountId: "other", to: "dm:alice", + isError: true, text: "⚠️ agent failed before reply: foreign account failure", }); const inbound = state.addInboundMessage({ diff --git a/extensions/qa-lab/src/qa-transport.ts b/extensions/qa-lab/src/qa-transport.ts index d2f4d748016f..3aba2e13995f 100644 --- a/extensions/qa-lab/src/qa-transport.ts +++ b/extensions/qa-lab/src/qa-transport.ts @@ -228,7 +228,7 @@ export function findFailureOutboundMessage( (message) => message.direction === "outbound" && (!options?.accountId || message.accountId === options.accountId) && - Boolean(extractQaFailureReplyText(message.text)), + Boolean(extractQaFailureReplyText(message)), ); } @@ -238,7 +238,7 @@ function assertNoFailureReplies( ) { const failureMessage = findFailureOutboundMessage(state, options); if (failureMessage) { - throw new Error(extractQaFailureReplyText(failureMessage.text) ?? failureMessage.text); + throw new Error(extractQaFailureReplyText(failureMessage) ?? failureMessage.text); } } @@ -564,9 +564,7 @@ export async function waitForQaTransportOutboundSequence(params: { // Failures belong to the account, even when a different conversation has a matching final. for (const { kind, message } of ownedEvents) { const failureReply = - kind === "deleted" || message.deleted - ? undefined - : extractQaFailureReplyText(message.text); + kind === "deleted" || message.deleted ? undefined : extractQaFailureReplyText(message); if (failureReply) { throw new Error(failureReply); } diff --git a/extensions/qa-lab/src/reply-failure.test.ts b/extensions/qa-lab/src/reply-failure.test.ts index c57260ec2c7a..850d5abb28c5 100644 --- a/extensions/qa-lab/src/reply-failure.test.ts +++ b/extensions/qa-lab/src/reply-failure.test.ts @@ -1,49 +1,32 @@ -// Qa Lab tests cover reply failure plugin behavior. +// Qa Lab tests cover typed reply failure markers and independent leak detection. import { describe, expect, it } from "vitest"; import { extractQaFailureReplyText, extractQaVisibleReplyLeakText } from "./reply-failure.js"; describe("extractQaFailureReplyText", () => { it("returns undefined for normal assistant replies", () => { expect( - extractQaFailureReplyText("Yes, precious. The build is green and a little cursed."), + extractQaFailureReplyText({ + text: "Yes, precious. The build is green and a little cursed.", + }), ).toBe(undefined); }); - it("classifies the generic external fallback reply as a failure", () => { - expect( - extractQaFailureReplyText( - "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.", - ), - ).toContain("Something went wrong while processing your request."); + it("classifies marked failures without depending on copy wording", () => { + const text = "Any future user-facing failure wording can go here."; + expect(extractQaFailureReplyText({ text, isError: true })).toBe(text); }); - it.each([ - "⚠️ Agent couldn't generate a response. Please try again.", - "⚠️ Agent couldn't generate a response. Note: some tool actions may have already been executed — please verify before retrying.", - ])("classifies the canonical incomplete-turn warning as a failure: %s", (reply) => { - expect(extractQaFailureReplyText(reply)).toBe(reply); + it("does not classify legacy failure-looking copy without the marker", () => { + expect( + extractQaFailureReplyText({ + text: "⚠️ Something went wrong while processing your request.", + }), + ).toBeUndefined(); }); - it("classifies explicit provider auth guidance as a failure", () => { - expect( - extractQaFailureReplyText( - '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.', - ), - ).toContain('No API key found for provider "openai".'); - }); - - it("classifies curated missing-key guidance as a failure", () => { - expect( - extractQaFailureReplyText( - "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-luna` with the Codex OAuth profile, or set `OPENAI_API_KEY`, then try again.", - ), - ).toContain("Missing API key for OpenAI on the gateway."); - }); - - it("classifies leaked codex harness coordination chatter as a failure", () => { - expect( - extractQaFailureReplyText("checking thread context; then post a tight progress reply here."), - ).toContain("checking thread context"); + it("classifies leaked harness coordination chatter independently", () => { + const text = "checking thread context; then post a tight progress reply here."; + expect(extractQaFailureReplyText({ text })).toContain("checking thread context"); }); }); diff --git a/extensions/qa-lab/src/reply-failure.ts b/extensions/qa-lab/src/reply-failure.ts index 8bf559a84a7c..0b8d56e2dd8e 100644 --- a/extensions/qa-lab/src/reply-failure.ts +++ b/extensions/qa-lab/src/reply-failure.ts @@ -1,21 +1,4 @@ // Qa Lab plugin module implements reply failure behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const FAILURE_REPLY_PREFIXES = [ - "⚠️ something went wrong while processing your request.", - "⚠️ agent couldn't generate a response", - "⚠️ session history got out of sync.", - "⚠️ session history was corrupted.", - "⚠️ context overflow", - "⚠️ message ordering conflict.", - "⚠️ model login expired on the gateway", - "⚠️ model login failed on the gateway", - "⚠️ agent failed before reply:", - "⚠️ ✉️ message failed", - "⚠️ no api key found for provider ", - "⚠️ missing api key for ", -]; - const VISIBLE_REPLY_LEAK_PATTERNS = [ /\bchecking thread context\b/i, /\bthread context thin\b/i, @@ -41,13 +24,15 @@ export function extractQaVisibleReplyLeakText(text: string): string | undefined return undefined; } -export function extractQaFailureReplyText(text: string): string | undefined { - const trimmed = text.trim(); +export function extractQaFailureReplyText(message: { + text: string; + isError?: boolean; +}): string | undefined { + const trimmed = message.text.trim(); if (!trimmed) { return undefined; } - const lower = normalizeLowercaseStringOrEmpty(trimmed); - if (FAILURE_REPLY_PREFIXES.some((prefix) => lower.startsWith(prefix))) { + if (message.isError === true) { return trimmed; } const visibleReplyLeak = extractQaVisibleReplyLeakText(trimmed); diff --git a/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts b/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts index e0b8d9a86b5d..26e6f98f342a 100644 --- a/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts +++ b/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts @@ -11,18 +11,22 @@ const classifiedFailureReplies = [ { failureName: "provider failure", failureText: '⚠️ No API key found for provider "openai".', + isError: true, }, { failureName: "delivery failure", failureText: "⚠️ ✉️ Message failed", + isError: true, }, { failureName: "missing tool failure", failureText: "Read: AGENT.md\nEvidence snippet: Tool read not found\nStatus: blocked", + isError: false, }, { failureName: "internal coordination leak", failureText: "checking thread context; then post a tight progress reply here.", + isError: false, }, ] as const; @@ -113,15 +117,16 @@ describe("character scenario transcript safety", () => { it.each( characterScenarioIds.flatMap((scenarioId) => - classifiedFailureReplies.map(({ failureName, failureText }) => ({ + classifiedFailureReplies.map(({ failureName, failureText, isError }) => ({ scenarioId, failureName, failureText, + isError, })), ), )( "rejects a $failureName after an actual reply in $scenarioId", - async ({ scenarioId, failureText }) => { + async ({ scenarioId, failureText, isError }) => { const state = createQaBusState(); const firstReply = "The build is green, and I am here."; let waitCount = 0; @@ -134,6 +139,7 @@ describe("character scenario transcript safety", () => { accountId: "qa-channel", to: "dm:alice", text: waitCount++ === 0 ? firstReply : failureText, + ...(waitCount > 1 && isError ? { isError: true } : {}), }); }), }), diff --git a/extensions/qa-lab/src/suite-runtime-transport.test.ts b/extensions/qa-lab/src/suite-runtime-transport.test.ts index 1f35220a3e5f..eaf93dca9730 100644 --- a/extensions/qa-lab/src/suite-runtime-transport.test.ts +++ b/extensions/qa-lab/src/suite-runtime-transport.test.ts @@ -15,6 +15,7 @@ describe("qa suite transport helpers", () => { const state = createQaBusState(); state.addOutboundMessage({ to: "dm:qa-operator", + isError: true, text: "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.", senderId: "openclaw", senderName: "OpenClaw QA", @@ -36,6 +37,7 @@ describe("qa suite transport helpers", () => { state.addOutboundMessage({ to: "dm:qa-operator", + isError: true, text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.', senderId: "openclaw", senderName: "OpenClaw QA", @@ -54,6 +56,7 @@ describe("qa suite transport helpers", () => { state.addOutboundMessage({ to: "channel:qa-room", + isError: true, text: "⚠️ ✉️ Message failed", senderId: "openclaw", senderName: "OpenClaw QA", @@ -150,6 +153,7 @@ describe("qa suite transport helpers", () => { state.addOutboundMessage({ accountId: "other", to: "dm:qa-operator", + isError: true, text: "⚠️ agent failed before reply: foreign account failure", }); const expected = state.addOutboundMessage({ @@ -185,6 +189,7 @@ describe("qa suite transport helpers", () => { state.addOutboundMessage({ to: "dm:qa-operator", + isError: true, text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.', senderId: "openclaw", senderName: "OpenClaw QA", @@ -232,6 +237,7 @@ describe("qa suite transport helpers", () => { state.addOutboundMessage({ to: "dm:qa-operator", + isError: true, text: '⚠️ No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai/gpt-5.6-luna with the Codex OAuth profile, or set OPENAI_API_KEY for direct OpenAI API access.', senderId: "openclaw", senderName: "OpenClaw QA", diff --git a/extensions/qa-lab/src/suite-runtime-transport.ts b/extensions/qa-lab/src/suite-runtime-transport.ts index 1458e9ea8d84..7020a77a1499 100644 --- a/extensions/qa-lab/src/suite-runtime-transport.ts +++ b/extensions/qa-lab/src/suite-runtime-transport.ts @@ -28,7 +28,7 @@ async function waitForOutboundMessage( return await waitForQaTransportCondition(() => { const failureMessage = findFailureOutboundMessage(state, options); if (failureMessage) { - throw new Error(extractQaFailureReplyText(failureMessage.text) ?? failureMessage.text); + throw new Error(extractQaFailureReplyText(failureMessage) ?? failureMessage.text); } const match = state .getSnapshot() @@ -43,7 +43,7 @@ async function waitForOutboundMessage( if (!match) { return undefined; } - const failureReply = extractQaFailureReplyText(match.text); + const failureReply = extractQaFailureReplyText(match); if (failureReply) { throw new Error(failureReply); } diff --git a/src/agents/auth-profiles/failure-copy.test.ts b/src/agents/auth-profiles/failure-copy.test.ts index 18a9afa8e20d..dfecb19e8cd0 100644 --- a/src/agents/auth-profiles/failure-copy.test.ts +++ b/src/agents/auth-profiles/failure-copy.test.ts @@ -3,17 +3,20 @@ * Verifies actionable recovery hints, transient-copy suppression, provider * naming, and diagnostic cause handling. */ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; const LOGIN_HINT_SENTINEL = "<>"; -vi.mock("../provider-auth-recovery-hint.js", () => ({ - buildProviderAuthRecoveryHint: (params: { provider: string }) => - `${LOGIN_HINT_SENTINEL}:${params.provider}`, -})); - import type { FailoverReason } from "../failover/signal.js"; -import { formatAuthProfileFailureMessage } from "./failure-copy.js"; +import { renderAuthProfileFailoverCopy } from "../failover/user-copy.js"; + +const formatAuthProfileFailureMessage = ( + params: Parameters[0], +) => + renderAuthProfileFailoverCopy({ + ...params, + recoveryHint: `${LOGIN_HINT_SENTINEL}:${params.provider}`, + }); const PROVIDER = "openai-codex"; @@ -32,7 +35,7 @@ const REASONS_WITHOUT_RECOVERY: readonly FailoverReason[] = [ "format", ]; -describe("formatAuthProfileFailureMessage", () => { +describe("renderAuthProfileFailoverCopy", () => { describe("recovery-hint dispatch", () => { it("includes the login command for reasons the user can act on", () => { for (const reason of REASONS_WITH_RECOVERY) { @@ -83,14 +86,14 @@ describe("formatAuthProfileFailureMessage", () => { describe("cause handling", () => { it("returns the cause text verbatim when the reason has no actionable copy", () => { - const cause = new Error("upstream provider returned 502"); + const causeText = "upstream provider returned 502"; const message = formatAuthProfileFailureMessage({ reason: "unknown", provider: PROVIDER, allInCooldown: false, - cause, + causeText, }); - expect(message).toBe(cause.message); + expect(message).toBe(causeText); }); it("appends a diagnostic suffix when the cause adds detail beyond the description", () => { @@ -98,7 +101,7 @@ describe("formatAuthProfileFailureMessage", () => { reason: "auth", provider: PROVIDER, allInCooldown: false, - cause: new Error("invalid_grant"), + causeText: "invalid_grant", }); expect(message).toContain("(invalid_grant)"); }); @@ -118,7 +121,7 @@ describe("formatAuthProfileFailureMessage", () => { reason: "auth", provider: PROVIDER, allInCooldown: false, - cause: new Error(description), + causeText: description, }); expect(withDuplicateCause).toBe(withoutCause); }); diff --git a/src/agents/auth-profiles/failure-copy.ts b/src/agents/auth-profiles/failure-copy.ts deleted file mode 100644 index 30110b0b78d5..000000000000 --- a/src/agents/auth-profiles/failure-copy.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * User-facing auth-profile failure copy. - * Maps failover reasons into provider-specific recovery guidance while keeping - * raw error detail as a short diagnostic suffix. - */ -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { formatErrorMessage } from "../../infra/errors.js"; -import type { FailoverReason } from "../failover/signal.js"; -import { buildProviderAuthRecoveryHint } from "../provider-auth-recovery-hint.js"; - -type AuthProfileFailureCopyParams = { - reason: FailoverReason; - provider: string; - /** - * True when the failure was reached because every configured profile is in - * cooldown / blocked. False when an attempt to use a specific profile threw - * (e.g. credential lookup failed). The two paths produce different copy - * because only the cooldown case implies "wait or rotate"; the other case - * implies "the credential itself is broken". - */ - allInCooldown: boolean; - /** - * Underlying error that triggered the failover, if any. Used to append a - * short diagnostic suffix and to fall back to the original message when no - * structured recovery copy applies. - */ - cause?: unknown; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}; - -function describeReason( - reason: FailoverReason, - provider: string, - allInCooldown: boolean, -): string | null { - if (allInCooldown) { - switch (reason) { - case "auth": - case "session_expired": - return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`; - case "auth_permanent": - return `${provider} isn't accepting your saved login anymore.`; - case "billing": - return `${provider} rejected the request — looks like a billing issue on the account.`; - case "rate_limit": - return `${provider} is asking us to slow down. Please wait a moment before trying again.`; - case "overloaded": - return `${provider} is overloaded right now. Please wait a moment before trying again.`; - case "timeout": - return `${provider} hasn't been responding. Please wait a moment before trying again.`; - case "model_not_found": - return `${provider} can't find the model you're using right now.`; - case "server_error": - return `${provider} is having issues right now. Please wait a moment before trying again.`; - default: - return `Couldn't reach ${provider} with any of your saved logins right now.`; - } - } - switch (reason) { - case "auth": - case "session_expired": - return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`; - case "auth_permanent": - return `${provider} isn't accepting your saved login.`; - case "billing": - return `${provider} rejected the request — looks like a billing issue on the account.`; - default: - return null; - } -} - -function shouldIncludeRecoveryHint(reason: FailoverReason): boolean { - switch (reason) { - case "auth": - case "auth_permanent": - case "session_expired": - case "billing": - return true; - case "rate_limit": - case "overloaded": - case "timeout": - case "tls_certificate": - case "server_error": - case "model_not_found": - case "format": - return false; - default: - return true; - } -} - -function diagnosticSuffix(cause: unknown, primary: string): string | null { - if (cause === undefined || cause === null) { - return null; - } - const text = formatErrorMessage(cause).trim(); - if (!text || primary.includes(text)) { - return null; - } - return ` (${text})`; -} - -/** - * Single source of truth for user-facing copy when an auth-profile rotation - * fails. Composes a reason-specific sentence with an actionable next-step - * derived from the provider's plugin manifest (`buildProviderAuthRecoveryHint`). - * - * Falls back to the underlying error's text when the reason maps to nothing - * actionable, so we never produce worse copy than the raw error. - */ -export function formatAuthProfileFailureMessage(params: AuthProfileFailureCopyParams): string { - const description = describeReason(params.reason, params.provider, params.allInCooldown); - if (!description) { - const causeText = params.cause ? formatErrorMessage(params.cause).trim() : ""; - if (causeText) { - return causeText; - } - return `Couldn't reach ${params.provider} with any of your saved logins right now.`; - } - const hint = shouldIncludeRecoveryHint(params.reason) - ? buildProviderAuthRecoveryHint({ - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - : null; - const suffix = diagnosticSuffix(params.cause, description); - const parts = [description]; - if (hint) { - parts.push(hint); - } - const message = parts.join(" "); - return suffix ? `${message}${suffix}` : message; -} diff --git a/src/agents/bash-tools.exec-approval-followup.ts b/src/agents/bash-tools.exec-approval-followup.ts index 115d4a7a7363..6c736f693552 100644 --- a/src/agents/bash-tools.exec-approval-followup.ts +++ b/src/agents/bash-tools.exec-approval-followup.ts @@ -31,7 +31,7 @@ import { buildExecApprovalContinuationFallbackPrompt, buildExecApprovalContinuationPrompt, } from "./bash-tools.exec-approval-output.js"; -import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js"; import { formatExecDeniedUserMessage, isExecDeniedResultText, @@ -169,7 +169,7 @@ function formatDirectExecApprovalFollowupText( if (parsed.kind === "finished") { const metadata = normalizeLowercaseStringOrEmpty(parsed.metadata); const body = redactToolPayloadText( - sanitizeUserFacingText(parsed.body, { + renderUserFacingText(parsed.body, { errorContext: !metadata.includes("code 0"), }), ).trim(); @@ -188,13 +188,13 @@ function formatDirectExecApprovalFollowupText( if (parsed.kind === "completed") { const body = redactToolPayloadText( - sanitizeUserFacingText(parsed.body, { errorContext: true }), + renderUserFacingText(parsed.body, { errorContext: true }), ).trim(); return body || "Background command finished."; } return ( - redactToolPayloadText(sanitizeUserFacingText(parsed.raw, { errorContext: true })).trim() || null + redactToolPayloadText(renderUserFacingText(parsed.raw, { errorContext: true })).trim() || null ); } diff --git a/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts index 522558f0014a..721fe487fd54 100644 --- a/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts +++ b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts @@ -13,6 +13,7 @@ import { isRawApiErrorPayload, } from "./embedded-agent-helpers.js"; import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js"; import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js"; describe("formatAssistantErrorText", () => { @@ -71,10 +72,10 @@ describe("formatAssistantErrorText", () => { expected: "The AI service is temporarily overloaded. Please try again in a moment.", }, { - title: "preserves overload wording for Z.AI rate-limit errors", + title: "uses classified rate-limit copy for Z.AI rate-limit errors", errorText: '429 status code (exceeded limit)\n{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}', - expected: "The AI service is temporarily overloaded. Please try again in a moment.", + expected: "⚠️ API rate limit reached. Please try again later.", }, { title: "rewrites generic provider internal errors without support request ids", @@ -757,7 +758,7 @@ describe("formatBillingErrorMessage — authMode neutral copy (#80877)", () => { describe("sanitizeUserFacingText — streaming JSON parse error (#59076)", () => { it("rewrites transport-classified malformed streaming fragments in error context", () => { - const result = sanitizeUserFacingText(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, { + const result = renderUserFacingText(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, { errorContext: true, }); expect(result).toBe("LLM streaming response contained a malformed fragment. Please try again."); diff --git a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts index ac37cd3350a3..1396e83260b6 100644 --- a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts @@ -14,6 +14,7 @@ import { } from "./embedded-agent-helpers.js"; import { stripThoughtSignatures } from "./embedded-agent-helpers/bootstrap.js"; import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js"; import { formatAgentInternalEventsForPrompt } from "./internal-events.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, @@ -57,18 +58,18 @@ describe("sanitizeUserFacingText", () => { ); it("sanitizes role ordering errors", () => { - const result = sanitizeUserFacingText("400 Incorrect role information", { errorContext: true }); + const result = renderUserFacingText("400 Incorrect role information", { errorContext: true }); expect(result).toContain("Message ordering conflict"); }); it("sanitizes HTTP status errors with error hints", () => { - expect(sanitizeUserFacingText("500 Internal Server Error", { errorContext: true })).toBe( + expect(renderUserFacingText("500 Internal Server Error", { errorContext: true })).toBe( "HTTP 500: Internal Server Error", ); }); it("preserves a provider-completed finish_reason error", () => { - expect(sanitizeUserFacingText("Provider finish_reason: error", { errorContext: true })).toBe( + expect(renderUserFacingText("Provider finish_reason: error", { errorContext: true })).toBe( "Provider finish_reason: error", ); }); @@ -77,7 +78,7 @@ describe("sanitizeUserFacingText", () => { "Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.", "Request size exceeds model context window", ])("sanitizes direct context-overflow error: %s", (text) => { - expect(sanitizeUserFacingText(text, { errorContext: true })).toContain( + expect(renderUserFacingText(text, { errorContext: true })).toContain( "Context overflow: prompt too large for the model.", ); }); @@ -85,7 +86,7 @@ describe("sanitizeUserFacingText", () => { it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => { const text = 'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}'; - expect(sanitizeUserFacingText(text, { errorContext: true })).toContain( + expect(renderUserFacingText(text, { errorContext: true })).toContain( "Context overflow: prompt too large for the model.", ); }); @@ -112,12 +113,12 @@ describe("sanitizeUserFacingText", () => { it("rewrites billing error-shaped text with errorContext", () => { const text = "billing: please upgrade your plan"; - expect(sanitizeUserFacingText(text, { errorContext: true })).toContain("billing error"); + expect(renderUserFacingText(text, { errorContext: true })).toContain("billing error"); }); it("rewrites exec denied payloads with errorContext", () => { expect( - sanitizeUserFacingText("Exec denied (gateway id=req-1, approval-timeout): bash -lc ls", { + renderUserFacingText("Exec denied (gateway id=req-1, approval-timeout): bash -lc ls", { errorContext: true, }), ).toBe("Command did not run: approval timed out."); @@ -125,7 +126,7 @@ describe("sanitizeUserFacingText", () => { it("sanitizes raw API error payloads", () => { const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; - expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe( + expect(renderUserFacingText(raw, { errorContext: true })).toBe( "LLM error server_error: Something exploded", ); }); @@ -138,7 +139,7 @@ describe("sanitizeUserFacingText", () => { it("sanitizes Codex error-prefixed API payloads", () => { const raw = 'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}'; - expect(sanitizeUserFacingText(raw, { errorContext: true })).toBe( + expect(renderUserFacingText(raw, { errorContext: true })).toBe( "LLM error server_error: Something exploded", ); }); @@ -146,7 +147,7 @@ describe("sanitizeUserFacingText", () => { it("sanitizes Codex error-prefixed API payloads without explicit errorContext", () => { const raw = 'Codex error: {"type":"error","error":{"type":"server_error","message":"Something exploded"},"sequence_number":2}'; - expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); + expect(renderUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); }); it("keeps regular JSON examples intact without explicit errorContext", () => { @@ -157,7 +158,7 @@ describe("sanitizeUserFacingText", () => { it("preserves specialized context overflow guidance for raw API payloads", () => { const raw = '{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; - expect(sanitizeUserFacingText(raw, { errorContext: true })).toContain( + expect(renderUserFacingText(raw, { errorContext: true })).toContain( "Context overflow: prompt too large for the model.", ); }); @@ -165,20 +166,20 @@ describe("sanitizeUserFacingText", () => { it("preserves specialized context overflow guidance for Codex-prefixed API payloads", () => { const raw = 'Codex error: {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}'; - expect(sanitizeUserFacingText(raw, { errorContext: true })).toContain( + expect(renderUserFacingText(raw, { errorContext: true })).toContain( "Context overflow: prompt too large for the model.", ); }); it("returns a friendly message for rate limit errors in Error: prefixed payloads", () => { - expect(sanitizeUserFacingText("Error: 429 Rate limit exceeded", { errorContext: true })).toBe( + expect(renderUserFacingText("Error: 429 Rate limit exceeded", { errorContext: true })).toBe( "⚠️ API rate limit reached. Please try again later.", ); }); it("preserves rate limit reset details that use resets wording", () => { expect( - sanitizeUserFacingText("Error: Rate limit reached, resets 6pm (UTC)", { + renderUserFacingText("Error: Rate limit reached, resets 6pm (UTC)", { errorContext: true, }), ).toBe("⚠️ Rate limit reached, resets 6pm (UTC)"); @@ -186,7 +187,7 @@ describe("sanitizeUserFacingText", () => { it("returns a model-switch hint for OpenAI model capacity errors", () => { expect( - sanitizeUserFacingText( + renderUserFacingText( "OpenAI error: Selected model is at capacity. Please try a different model.", { errorContext: true, @@ -197,7 +198,7 @@ describe("sanitizeUserFacingText", () => { it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => { expect( - sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", { + renderUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", { errorContext: true, }), ).toBe("LLM request failed: connection refused by the provider endpoint."); @@ -206,7 +207,7 @@ describe("sanitizeUserFacingText", () => { it.each(["disk full", "ENOSPC: no space left on device"])( "rewrites disk-space failures with errorContext: %s", (input) => { - expect(sanitizeUserFacingText(input, { errorContext: true })).toBe( + expect(renderUserFacingText(input, { errorContext: true })).toBe( "OpenClaw could not write local session data because the disk is full. Free some disk space and try again.", ); }, @@ -214,7 +215,7 @@ describe("sanitizeUserFacingText", () => { it("sanitizes invalid streaming event order errors", () => { expect( - sanitizeUserFacingText( + renderUserFacingText( 'Unexpected event order, got message_start before receiving "message_stop"', { errorContext: true }, ), diff --git a/src/agents/embedded-agent-helpers.ts b/src/agents/embedded-agent-helpers.ts index b01d0aa03211..b46f9a796091 100644 --- a/src/agents/embedded-agent-helpers.ts +++ b/src/agents/embedded-agent-helpers.ts @@ -31,10 +31,9 @@ export type { ProviderRuntimeFailureKind } from "./embedded-agent-helpers/provid export { BILLING_ERROR_USER_MESSAGE, formatBillingErrorMessage, - formatRateLimitOrOverloadedErrorCopy, getApiErrorPayloadFingerprint, isRawApiErrorPayload, -} from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +} from "./failover/user-copy.js"; export { formatRawAssistantErrorForUi, parseApiErrorInfo, diff --git a/src/agents/embedded-agent-helpers/error-text.ts b/src/agents/embedded-agent-helpers/error-text.ts index 053512b275e5..292cb75b0156 100644 --- a/src/agents/embedded-agent-helpers/error-text.ts +++ b/src/agents/embedded-agent-helpers/error-text.ts @@ -9,6 +9,7 @@ import { parseApiErrorInfo, } from "../../shared/assistant-error-format.js"; import { + classifyFailoverReason, isBilling429MessageForProvider, isBillingErrorMessage, isContextOverflowError, @@ -18,26 +19,23 @@ import { isReasoningConstraintErrorMessage, isTimeoutErrorMessage, } from "../failover/classify.js"; +import { + AUTH_INVALID_TOKEN_USER_TEXT, + formatBillingErrorMessage, + formatDiskSpaceErrorCopy, + formatTransportErrorCopy, + isInvalidStreamingEventOrderError, + isLikelyHttpErrorText, + isRawApiErrorPayload, + isStreamingJsonParseError, + renderRateLimitOrOverloadedCopy, +} from "../failover/user-copy.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox/runtime-status.js"; import { buildAssistantFailoverSignal } from "./assistant-message-failures.js"; import { classifyProviderRuntimeFailureKind } from "./provider-runtime-failure.js"; -import { - formatBillingErrorMessage, - formatDiskSpaceErrorCopy, - formatRateLimitOrOverloadedErrorCopy, - formatTransportErrorCopy, - isInvalidStreamingEventOrderError, - isLikelyHttpErrorText as isLikelyHttpErrorTextFromSanitizer, - isRawApiErrorPayload, - isStreamingJsonParseError, -} from "./sanitize-user-facing-text.js"; const log = createSubsystemLogger("errors"); const sandboxToolPolicyAuditMessages = new WeakSet(); export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed."; -export const AUTH_INVALID_TOKEN_USER_TEXT = - "Authentication failed (provider returned HTTP 401). " + - "Your provider token may have expired — try the request again in a moment. " + - "If the failure persists, re-authenticate this provider."; const PROVIDER_SCHEMA_REJECTION_USER_TEXT = "LLM request failed: provider rejected the request schema or tool payload."; const MODEL_NOT_FOUND_USER_TEXT = @@ -217,7 +215,11 @@ export function formatAssistantErrorText( return formatBillingErrorMessage(opts?.provider, opts?.model ?? msg.model, opts?.authMode); } - const transientCopy = formatRateLimitOrOverloadedErrorCopy(raw); + const failoverReason = classifyFailoverReason(raw, { provider: opts?.provider }); + const transientCopy = + failoverReason === "rate_limit" || failoverReason === "overloaded" + ? renderRateLimitOrOverloadedCopy({ reason: failoverReason, raw }) + : undefined; if (transientCopy) { return transientCopy; } @@ -249,7 +251,7 @@ export function formatAssistantErrorText( return PROVIDER_SCHEMA_REJECTION_USER_TEXT; } - if (isLikelyHttpErrorTextFromSanitizer(raw) || isRawApiErrorPayload(raw)) { + if (isRawApiErrorPayload(raw) || isLikelyHttpErrorText(raw)) { return formatRawAssistantErrorForUi(raw); } diff --git a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts index 2104e3afc333..327c3725bd0c 100644 --- a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts +++ b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts @@ -1,22 +1,6 @@ -import { stableStringify } from "@openclaw/normalization-core"; -/** - * Converts raw provider/transport errors into concise user-facing copy. - */ -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "../../../packages/normalization-core/src/string-coerce.js"; +/** Strips internal scaffolding from text before user-facing delivery. */ import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js"; import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js"; -import { - extractLeadingHttpStatus, - formatRawAssistantErrorForUi, - isCloudflareOrHtmlErrorPage, - isGenericProviderInternalError, - MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, - parseApiErrorInfo, - parseApiErrorPayload, -} from "../../shared/assistant-error-format.js"; import { coerceChatContentText } from "../../shared/chat-content.js"; import { stripAssistantInternalTraceLines, @@ -26,353 +10,13 @@ import { } from "../../shared/text/assistant-visible-text.js"; import { findCodeRegions } from "../../shared/text/code-regions.js"; import { stripFinalTags } from "../../shared/text/final-tags.js"; -import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; -import { - isBillingErrorMessage, - isOverloadedErrorMessage, - isProviderCompletedErrorFinishReasonMessage, - isRateLimitErrorMessage, - isTimeoutErrorMessage, -} from "../failover/classify.js"; import { stripInternalRuntimeContext } from "../internal-runtime-context.js"; -/** Format the billing failure copy with optional provider/model context. - * - * When `authMode` is `"oauth"` or `"token"` (i.e. Anthropic Max or a static - * bearer-token subscription) the user has no API key to top up, so we emit - * neutral copy that directs them to check their account instead (#80877). - */ -export function formatBillingErrorMessage( - provider?: string, - model?: string, - authMode?: string, -): string { - const providerName = provider?.trim(); - const modelName = model?.trim(); - const providerLabel = - providerName && modelName ? `${providerName} (${modelName})` : providerName || undefined; - - // OAuth and static-token credentials do not have an API key to top up. - const isSubscriptionAuth = authMode === "oauth" || authMode === "token"; - if (isSubscriptionAuth) { - if (providerLabel) { - return `⚠️ ${providerLabel} returned a billing error — check your account for subscription or usage limits, then try again.`; - } - return "⚠️ API provider returned a billing error — check your account for subscription or usage limits, then try again."; - } - - if (providerLabel) { - return `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.`; - } - return "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key."; -} - -export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage(); - -const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later."; -const MODEL_CAPACITY_ERROR_USER_MESSAGE = - "⚠️ Selected model is at capacity. Try a different model, or wait and retry."; -const OVERLOADED_ERROR_USER_MESSAGE = - "The AI service is temporarily overloaded. Please try again in a moment."; const TOOL_CALLS_OMITTED_PLACEHOLDER_LINE_RE = /^[ \t]*\[tool calls omitted\][ \t]*$/i; -const ERROR_PREFIX_RE = - /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; -const CONTEXT_OVERFLOW_ERROR_HEAD_RE = - /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; -const HTTP_ERROR_HINTS = [ - "error", - "bad request", - "not found", - "unauthorized", - "forbidden", - "internal server", - "service unavailable", - "gateway", - "rate limit", - "overloaded", - "timeout", - "timed out", - "invalid", - "too many requests", - "permission", -]; -const RATE_LIMIT_SPECIFIC_HINT_RE = - /\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i; -const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i; -const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384; -const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i; - -function extractProviderRateLimitMessage(raw: string): string | undefined { - const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim(); - const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix); - const candidate = - info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix); - - if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) { - return undefined; - } - - if (isCloudflareOrHtmlErrorPage(withoutPrefix)) { - return undefined; - } - - const trimmed = candidate.trim(); - if ( - trimmed.length > 300 || - trimmed.startsWith("{") || - /^(?: NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH) { - return false; - } - if (!NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE.test(raw)) { - return false; - } - const info = parseApiErrorInfo(raw); - if (!info) { - return false; - } - if (isLikelyProviderErrorType(info.type)) { - return true; - } - if (info.httpCode) { - const parsedCode = Number(info.httpCode); - if (Number.isFinite(parsedCode) && parsedCode >= 400) { - return true; - } - } - return false; -} function stripFinalTagsFromText(text: unknown): string { const normalized = coerceChatContentText(text); - if (!normalized) { - return normalized; - } - return stripFinalTags(normalized); + return normalized ? stripFinalTags(normalized) : normalized; } function stripToolCallsOmittedPlaceholderLines(text: string): string { @@ -400,39 +44,17 @@ function collapseConsecutiveDuplicateBlocks(text: string): string { if (blocks.length < 2) { return text; } - - const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " "); const result: string[] = []; let lastNormalized: string | null = null; - for (const block of blocks) { - const normalized = normalizeBlock(block); + const normalized = block.trim().replace(/\s+/g, " "); if (lastNormalized && normalized === lastNormalized) { continue; } result.push(block.trim()); lastNormalized = normalized; } - - if (result.length === blocks.length) { - return text; - } - return result.join("\n\n"); -} - -export function isLikelyHttpErrorText(raw: string): boolean { - if (isCloudflareOrHtmlErrorPage(raw)) { - return true; - } - const status = extractLeadingHttpStatus(raw); - if (!status) { - return false; - } - if (status.code < 400) { - return false; - } - const message = normalizeLowercaseStringOrEmpty(status.rest); - return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); + return result.length === blocks.length ? text : result.join("\n\n"); } export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string { @@ -440,92 +62,23 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo if (!raw) { return raw; } - const errorContext = opts?.errorContext ?? false; const stripped = stripInboundMetadata(stripInternalRuntimeContext(stripFinalTagsFromText(raw))); const withoutToolCallXml = stripToolCallXmlTags(stripMinimaxToolCallXml(stripped), { stripFunctionCallsXmlPayloads: true, }); // Replay repair may synthesize this placeholder to keep provider transcripts valid. - // It is internal scaffolding, so drop standalone placeholder lines before delivery - // while preserving ordinary inline mentions a user may be discussing. + // It is internal scaffolding, so drop standalone placeholder lines before delivery. const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml); - const withoutInternalTraceLines = errorContext + const withoutInternalTraceLines = opts?.errorContext ? stripAssistantInternalTraceLines(withoutPlaceholder) : withoutPlaceholder; const withoutToolCallBlocks = stripPlainTextToolCallBlocks( stripLegacyBracketToolCallBlocks(withoutInternalTraceLines), { resolveProtectedRanges: findCodeRegions }, ); - const trimmed = withoutToolCallBlocks.trim(); - if (!trimmed) { + if (!withoutToolCallBlocks.trim()) { return ""; } - - if (!errorContext && shouldRewriteRawPayloadWithoutErrorContext(trimmed)) { - return formatRawAssistantErrorForUi(trimmed); - } - if (errorContext) { - const execDeniedMessage = formatExecDeniedUserMessage(trimmed); - if (execDeniedMessage) { - return execDeniedMessage; - } - - const diskSpaceCopy = formatDiskSpaceErrorCopy(trimmed); - if (diskSpaceCopy) { - return diskSpaceCopy; - } - - if (/incorrect role information|roles must alternate/i.test(trimmed)) { - return ( - "Message ordering conflict - please try again. " + - "If this persists, use /new to start a fresh session." - ); - } - - // Keep this sanitizer on generic overflow heuristics only so callers can - // avoid importing the heavier provider-runtime-aware error classifier. - if (shouldRewriteContextOverflowText(trimmed)) { - return ( - "Context overflow: prompt too large for the model. " + - "Try /reset (or /new) to start a fresh session, or use a larger-context model." - ); - } - - if (isBillingErrorMessage(trimmed)) { - return BILLING_ERROR_USER_MESSAGE; - } - if (isGenericProviderInternalError(trimmed)) { - return formatRawAssistantErrorForUi(trimmed); - } - if (isInvalidStreamingEventOrderError(trimmed)) { - return "LLM request failed: provider returned an invalid streaming response. Please try again."; - } - if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { - return formatRawAssistantErrorForUi(trimmed); - } - if (isStreamingJsonParseError(trimmed)) { - return "LLM streaming response contained a malformed fragment. Please try again."; - } - if (ERROR_PREFIX_RE.test(trimmed)) { - const prefixedCopy = formatRateLimitOrOverloadedErrorCopy(trimmed); - if (prefixedCopy) { - return prefixedCopy; - } - const transportCopy = formatTransportErrorCopy(trimmed); - if (transportCopy) { - return transportCopy; - } - // finish_reason/stop-reason `error` is a completed provider failure, not a timeout (#109218). - if (isProviderCompletedErrorFinishReasonMessage(trimmed)) { - return formatRawAssistantErrorForUi(trimmed); - } - if (isTimeoutErrorMessage(trimmed)) { - return "LLM request timed out."; - } - return formatRawAssistantErrorForUi(trimmed); - } - } - const withoutLeadingEmptyLines = withoutToolCallBlocks.replace(/^(?:[ \t]*\r?\n)+/, ""); return collapseConsecutiveDuplicateBlocks(withoutLeadingEmptyLines); } diff --git a/src/agents/embedded-agent-helpers/user-facing-text.ts b/src/agents/embedded-agent-helpers/user-facing-text.ts new file mode 100644 index 000000000000..593bb0802877 --- /dev/null +++ b/src/agents/embedded-agent-helpers/user-facing-text.ts @@ -0,0 +1,7 @@ +import { renderSanitizedUserFacingText } from "../failover/user-copy.js"; +import { sanitizeUserFacingText } from "./sanitize-user-facing-text.js"; + +/** Compose internal-text stripping with the canonical failover copy renderer. */ +export function renderUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string { + return renderSanitizedUserFacingText(sanitizeUserFacingText(text, opts), opts); +} diff --git a/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts index 30f02c692393..e7d13582ae71 100644 --- a/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts @@ -1,6 +1,7 @@ // Coverage for deciding when embedded run results should trigger model fallback. import { describe, expect, it } from "vitest"; -import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../auto-reply/reply/agent-runner-failure-copy.js"; +import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js"; +import { runWithModelFallback } from "../model-fallback-runner.js"; import { classifyEmbeddedAgentRunResultForModelFallback } from "./result-fallback-classifier.js"; describe("classifyEmbeddedAgentRunResultForModelFallback", () => { @@ -127,25 +128,42 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => { }); }); - it("classifies generic external runner failure text as fallback-worthy", () => { - const result = classifyEmbeddedAgentRunResultForModelFallback({ - provider: "claude-cli", - model: "claude-sonnet-4-6", - result: { - payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], - meta: { - durationMs: 42, - }, + it("advances to the configured fallback after a generic external runner failure", async () => { + const runs: Array<{ provider: string; model: string }> = []; + const result = await runWithModelFallback({ + cfg: undefined, + provider: "external", + model: "primary", + fallbacksOverride: ["external/fallback"], + skipAuthProfileRuntime: true, + run: async (provider, model) => { + runs.push({ provider, model }); + return runs.length === 1 + ? { + payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], + meta: { durationMs: 1 }, + } + : { payloads: [{ text: "fallback ok" }], meta: { durationMs: 1 } }; }, + classifyResult: ({ provider, model, result: runResult }) => + classifyEmbeddedAgentRunResultForModelFallback({ + provider, + model, + result: runResult, + }), }); - expect(result).toEqual({ - message: - "claude-cli/claude-sonnet-4-6 ended with a generic external runner failure: " + - GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + expect(runs).toEqual([ + { provider: "external", model: "primary" }, + { provider: "external", model: "fallback" }, + ]); + expect(result.result.payloads).toEqual([{ text: "fallback ok" }]); + expect(result.attempts[0]).toMatchObject({ + provider: "external", + model: "primary", reason: "format", code: "generic_external_run_failure", - rawError: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + error: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, }); }); @@ -195,80 +213,60 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => { expect(result).toBeNull(); }); - it("does not retry generic external runner failure text mixed with non-text visible content", () => { - const result = classifyEmbeddedAgentRunResultForModelFallback({ - provider: "claude-cli", - model: "claude-sonnet-4-6", - result: { - payloads: [ - { - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - mediaUrl: "https://example.com/failure-screenshot.png", - channelData: { delivered: true }, - }, - ], - meta: { - durationMs: 42, - }, + it.each([ + { + name: "non-text visible content", + payload: { + text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + mediaUrl: "https://example.com/failure-screenshot.png", + channelData: { delivered: true }, }, - }); - - expect(result).toBeNull(); - }); - - it("does not retry generic external runner failure text mixed with interactive content", () => { - const result = classifyEmbeddedAgentRunResultForModelFallback({ - provider: "claude-cli", - model: "claude-sonnet-4-6", - result: { - payloads: [ - { - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - interactive: { type: "button", label: "Retry" }, - }, - ], - meta: { - durationMs: 42, - }, + }, + { + name: "interactive content", + payload: { + text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + interactive: { type: "button", label: "Retry" }, }, - }); - - expect(result).toBeNull(); + }, + ])("does not retry generic external runner failure text with $name", ({ payload }) => { + expect( + classifyEmbeddedAgentRunResultForModelFallback({ + provider: "external", + model: "primary", + result: { payloads: [payload], meta: { durationMs: 42 } }, + }), + ).toBeNull(); }); it("does not retry generic external runner failure text after committed delivery", () => { - const result = classifyEmbeddedAgentRunResultForModelFallback({ - provider: "claude-cli", - model: "claude-sonnet-4-6", - result: { - payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], - messagingToolSentTexts: ["already delivered"], - meta: { - durationMs: 42, + expect( + classifyEmbeddedAgentRunResultForModelFallback({ + provider: "external", + model: "primary", + result: { + payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], + messagingToolSentTexts: ["already delivered"], + meta: { durationMs: 42 }, }, - }, - }); - - expect(result).toBeNull(); + }), + ).toBeNull(); }); - it("preserves hook block results with generic external runner failure text", () => { - const result = classifyEmbeddedAgentRunResultForModelFallback({ - provider: "claude-cli", - model: "claude-sonnet-4-6", - result: { - payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], - meta: { - durationMs: 42, - error: { - kind: "hook_block", - message: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + it("preserves hook blocks with generic external runner failure text", () => { + expect( + classifyEmbeddedAgentRunResultForModelFallback({ + provider: "external", + model: "primary", + result: { + payloads: [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], + meta: { + durationMs: 42, + error: { kind: "hook_block", message: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }, }, }, - }, - }); - - expect(result).toBeNull(); + }), + ).toBeNull(); }); it("preserves hook block results with auth-like error payload text", () => { diff --git a/src/agents/embedded-agent-runner/result-fallback-classifier.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.ts index 088ed5262dfd..71dd47df2255 100644 --- a/src/agents/embedded-agent-runner/result-fallback-classifier.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.ts @@ -1,10 +1,8 @@ -/** - * Classifies embedded-agent run results for model fallback decisions. - */ -import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../auto-reply/reply/agent-runner-failure-copy.js"; +/** Classifies embedded-agent run results for model fallback decisions. */ import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js"; import { classifyFailoverReason } from "../failover/classify.js"; import type { FailoverReason } from "../failover/signal.js"; +import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js"; import type { ModelFallbackResultClassification } from "../model-fallback-attempt.js"; import { hasCommittedOutboundDeliveryEvidence, diff --git a/src/agents/embedded-agent-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts index 3143e95e2c78..0bdcd0c9d52d 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -13,7 +13,6 @@ import { resolveProfilesUnavailableReason, resolveSubscriptionAuthModeForProfiles, } from "../../auth-profiles.js"; -import { formatAuthProfileFailureMessage } from "../../auth-profiles/failure-copy.js"; import { classifyFailoverReason, isFailoverErrorMessage, @@ -21,11 +20,13 @@ import { } from "../../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { shouldUseTransientCooldownProbeSlot } from "../../failover-policy.js"; +import { renderAuthProfileFailoverCopy } from "../../failover/user-copy.js"; import { getApiKeyForModel, MissingProviderAuthError, type ResolvedProviderAuth, } from "../../model-auth.js"; +import { buildProviderAuthRecoveryHint } from "../../provider-auth-recovery-hint.js"; import { providerModelRouteAcceptsAuthMode } from "../../provider-model-route-auth.js"; import { applyPreparedRuntimeAuthToModel, @@ -403,14 +404,19 @@ export function createEmbeddedRunAuthController(params: { }); const message = failoverParams.message?.trim() || - formatAuthProfileFailureMessage({ + renderAuthProfileFailoverCopy({ reason, provider, allInCooldown: failoverParams.allInCooldown, - cause: failoverParams.error, - config: params.config, - workspaceDir: params.workspaceDir, - env: process.env, + causeText: failoverParams.error + ? formatErrorMessage(failoverParams.error).trim() + : undefined, + recoveryHint: buildProviderAuthRecoveryHint({ + provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: process.env, + }), }); if (params.fallbackConfigured) { const authMode = diff --git a/src/agents/embedded-agent-utils.ts b/src/agents/embedded-agent-utils.ts index 7e69b6bf9a7e..a65496360695 100644 --- a/src/agents/embedded-agent-utils.ts +++ b/src/agents/embedded-agent-utils.ts @@ -15,6 +15,7 @@ import { sanitizeAssistantVisibleText, } from "../shared/text/assistant-visible-text.js"; import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "./embedded-agent-helpers/user-facing-text.js"; import type { AgentMessage } from "./runtime/index.js"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; @@ -41,7 +42,9 @@ export function sanitizeAssistantVisibleStreamText(text: string): string { function finalizeAssistantExtraction(msg: AssistantMessage, extracted: string): string { const errorContext = msg.stopReason === "error"; - return sanitizeUserFacingText(extracted, { errorContext }); + return errorContext + ? renderUserFacingText(extracted, { errorContext: true }) + : sanitizeUserFacingText(extracted); } type AssistantTextExtractionResult = { diff --git a/src/agents/failover/classify.message-predicates.test.ts b/src/agents/failover/classify.message-predicates.test.ts index 2d02ae34191e..afb5158f37ef 100644 --- a/src/agents/failover/classify.message-predicates.test.ts +++ b/src/agents/failover/classify.message-predicates.test.ts @@ -1,6 +1,5 @@ // Covers provider-specific failover matcher regressions. import { describe, expect, it } from "vitest"; -import { formatRateLimitOrOverloadedErrorCopy } from "../embedded-agent-helpers/sanitize-user-facing-text.js"; import { classifyFailoverReason, isAuthErrorMessage, @@ -12,6 +11,7 @@ import { isTimeoutErrorMessage, matchesFormatErrorPattern, } from "./classify.js"; +import { renderRateLimitOrOverloadedCopy } from "./user-copy.js"; describe("matchesFormatErrorPattern", () => { it("retains the direct format compatibility predicate", () => { @@ -276,16 +276,17 @@ describe("HTTP 429 overload wording (#98101)", () => { '{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}'; expect(classifyFailoverReason(message)).toBe("rate_limit"); expect(classifyFailoverReason(`HTTP 429: ${message}`)).toBe("rate_limit"); - expect(formatRateLimitOrOverloadedErrorCopy(message)).toBe( - "The AI service is temporarily overloaded. Please try again in a moment.", + expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe( + "⚠️ API rate limit reached. Please try again later.", ); }); it("preserves actionable retry details when a rate limit also mentions overload", () => { expect( - formatRateLimitOrOverloadedErrorCopy( - "429 rate limit: service overloaded, try again in 30 seconds", - ), + renderRateLimitOrOverloadedCopy({ + reason: "rate_limit", + raw: "429 rate limit: service overloaded, try again in 30 seconds", + }), ).toBe("⚠️ rate limit: service overloaded, try again in 30 seconds"); }); }); diff --git a/src/agents/failover/failover-classification.corpus.test.ts b/src/agents/failover/failover-classification.corpus.test.ts index 81e47f01ef05..06c1bcc08dcd 100644 --- a/src/agents/failover/failover-classification.corpus.test.ts +++ b/src/agents/failover/failover-classification.corpus.test.ts @@ -49,7 +49,7 @@ const failoverClassificationCorpus = [ ...legacyBillingBCases, ...legacyProviderMatcherCases, ]; -import { formatRateLimitOrOverloadedErrorCopy } from "../embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderRateLimitOrOverloadedCopy } from "./user-copy.js"; function classifyReplyRequest(signal: FailoverSignal) { return resolveReplyFailoverFacts(signal, signal.message ?? "").providerRequestError; @@ -107,16 +107,16 @@ describe("cross-layer drift (documents current behavior, see refactor-02)", () = }); }); - it("uses rate-limit retry semantics but overloaded user copy", () => { + it("renders rate-limit copy from the classified reason", () => { const message = "429 Too Many Requests: model overloaded"; - // BUG(refactor-02): retry classification and user-copy precedence are inverted. + // FIXED(refactor-02): user copy follows the canonical failover reason. expect(classifyFailoverSignal({ message })).toEqual({ kind: "reason", reason: "rate_limit", }); - expect(formatRateLimitOrOverloadedErrorCopy(message)).toBe( - "The AI service is temporarily overloaded. Please try again in a moment.", + expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe( + "⚠️ API rate limit reached. Please try again later.", ); }); diff --git a/src/agents/failover/user-copy.test.ts b/src/agents/failover/user-copy.test.ts new file mode 100644 index 000000000000..60ffdee49905 --- /dev/null +++ b/src/agents/failover/user-copy.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + BILLING_ERROR_USER_MESSAGE, + renderBillingReplyCopy, + renderCliTimeoutReplyCopy, + renderMissingApiKeyReplyCopy, + renderRateLimitOrOverloadedCopy, + renderRateLimitReplyCopy, +} from "./user-copy.js"; + +describe("failover user copy", () => { + it("renders transient copy from the classified reason", () => { + const raw = "429 Too Many Requests: model overloaded"; + expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw })).toBe( + "⚠️ API rate limit reached. Please try again later.", + ); + expect(renderRateLimitOrOverloadedCopy({ reason: "overloaded", raw })).toBe( + "The AI service is temporarily overloaded. Please try again in a moment.", + ); + }); + + it("preserves actionable provider retry detail for classified rate limits", () => { + expect( + renderRateLimitOrOverloadedCopy({ + reason: "rate_limit", + raw: "429 rate limit: service overloaded, try again in 30 seconds", + }), + ).toBe("⚠️ rate limit: service overloaded, try again in 30 seconds"); + }); + + it("renders structured cooldown durations and exhausted model sets", () => { + const now = 1_000_000; + expect( + renderRateLimitReplyCopy({ + message: "limited", + reason: "rate_limit", + attempts: [{ provider: "openai", model: "gpt-a", reason: "rate_limit" }], + cooldownExpiry: now + 45_000, + nowMs: now, + }), + ).toBe("⚠️ Rate-limited — ready in ~45s. Please wait a moment."); + expect( + renderRateLimitReplyCopy({ + message: "limited", + reason: "rate_limit", + attempts: [ + { provider: "openai", model: "gpt-a", reason: "rate_limit" }, + { provider: "anthropic", model: "claude-b", reason: "overloaded" }, + ], + nowMs: now, + }), + ).toBe( + "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes.", + ); + }); + + it("uses neutral billing copy for subscription credentials", () => { + expect( + renderBillingReplyCopy({ + provider: "Anthropic", + model: "claude", + authMode: "oauth", + }), + ).toBe( + "⚠️ Anthropic (claude) returned a billing error — check your account for subscription or usage limits, then try again.", + ); + expect(renderBillingReplyCopy({})).toBe(BILLING_ERROR_USER_MESSAGE); + }); + + it("renders provider-safe missing-key guidance", () => { + expect(renderMissingApiKeyReplyCopy({ provider: "openai", providerGuidance: true })).toContain( + "Missing API key for OpenAI on the gateway", + ); + expect(renderMissingApiKeyReplyCopy({ provider: "provider-with-secret-name" })).toBe( + "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.", + ); + }); + + it("renders typed CLI timeout context without losing partial-work warnings", () => { + expect( + renderCliTimeoutReplyCopy({ + message: "openai/gpt-5.6-sol: CLI exceeded timeout (90s) and was terminated", + provider: "codex-cli", + cliTimeout: { + mode: "overall", + timeoutSeconds: 90, + observedActivity: true, + activeToolCount: 1, + backgroundTaskCount: 2, + }, + replayPrevented: true, + }), + ).toBe( + "⚠️ CLI turn (routing openai/gpt-5.6-sol): timed out after 90s (overall turn limit). The gateway is unaffected. It also stopped 2 CLI background tasks and 1 active CLI tool call; that work shares the parent CLI process. Effects may be partial; check before retrying. OpenClaw did not replay this turn automatically. For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise `agents.defaults.timeoutSeconds`.", + ); + }); +}); diff --git a/src/agents/failover/user-copy.ts b/src/agents/failover/user-copy.ts new file mode 100644 index 000000000000..79334919af12 --- /dev/null +++ b/src/agents/failover/user-copy.ts @@ -0,0 +1,697 @@ +import { stableStringify } from "@openclaw/normalization-core"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { + extractLeadingHttpStatus, + formatRawAssistantErrorForUi, + isCloudflareOrHtmlErrorPage, + isGenericProviderInternalError, + MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + parseApiErrorInfo, + parseApiErrorPayload, +} from "../../shared/assistant-error-format.js"; +import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; +import type { CliTimeoutContext, FallbackAttemptRecord } from "../failover-error.js"; +import { + classifyFailoverReason, + isPeriodicUsageLimitErrorMessage, + isProviderCompletedErrorFinishReasonMessage, +} from "./classify.js"; +import { + classifyProviderRequestFacets, + type ProviderRequestFacet, +} from "./request-error-facets.js"; +import type { FailoverClassification, FailoverReason } from "./signal.js"; + +type FailoverUserCopyContext = { + raw?: string; + provider?: string; + model?: string; + authMode?: string; +}; + +type FailoverBaseCopyRenderer = (context: FailoverUserCopyContext) => string | undefined; + +const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later."; +export const AUTH_INVALID_TOKEN_USER_TEXT = + "Authentication failed (provider returned HTTP 401). " + + "Your provider token may have expired — try the request again in a moment. " + + "If the failure persists, re-authenticate this provider."; +const MODEL_CAPACITY_ERROR_USER_MESSAGE = + "⚠️ Selected model is at capacity. Try a different model, or wait and retry."; +const OVERLOADED_ERROR_USER_MESSAGE = + "The AI service is temporarily overloaded. Please try again in a moment."; +const RATE_LIMIT_RETRY_MESSAGE = + "⚠️ The model request was rate-limited. Please try again in a few minutes."; +const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i; +const RATE_LIMIT_SPECIFIC_HINT_RE = + /\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i; +const ERROR_PREFIX_RE = + /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; +const CONTEXT_OVERFLOW_ERROR_HEAD_RE = + /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; +const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384; +const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i; + +/** Format billing copy with optional provider/model and credential context. */ +export function formatBillingErrorMessage( + provider?: string, + model?: string, + authMode?: string, +): string { + const providerName = provider?.trim(); + const modelName = model?.trim(); + const providerLabel = + providerName && modelName ? `${providerName} (${modelName})` : providerName || undefined; + const isSubscriptionAuth = authMode === "oauth" || authMode === "token"; + if (isSubscriptionAuth) { + return providerLabel + ? `⚠️ ${providerLabel} returned a billing error — check your account for subscription or usage limits, then try again.` + : "⚠️ API provider returned a billing error — check your account for subscription or usage limits, then try again."; + } + return providerLabel + ? `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.` + : "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key."; +} + +export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage(); + +function extractProviderRateLimitMessage(raw: string): string | undefined { + const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim(); + const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix); + const candidate = + info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix); + if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) { + return undefined; + } + if (isCloudflareOrHtmlErrorPage(withoutPrefix)) { + return undefined; + } + const trimmed = candidate.trim(); + if ( + trimmed.length > 300 || + trimmed.startsWith("{") || + /^(?: AUTH_INVALID_TOKEN_USER_TEXT, + auth_permanent: () => AUTH_INVALID_TOKEN_USER_TEXT, + format: () => "LLM request failed: provider rejected the request schema or tool payload.", + rate_limit: renderRateLimitBaseCopy, + overloaded: (context) => + MODEL_CAPACITY_ERROR_RE.test(context.raw ?? "") + ? MODEL_CAPACITY_ERROR_USER_MESSAGE + : OVERLOADED_ERROR_USER_MESSAGE, + billing: (context) => + formatBillingErrorMessage(context.provider, context.model, context.authMode), + server_error: () => "LLM request failed: provider returned an internal error.", + timeout: () => "LLM request timed out.", + tls_certificate: () => + "LLM request failed: TLS certificate validation rejected the provider endpoint. Check the endpoint hostname, proxy, and local certificate trust.", + context_overflow: () => + "Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.", + model_not_found: () => + "The selected model was not found by the provider. Check the model id or choose a different model.", + session_expired: () => "The provider session expired. Start a new session and try again.", + empty_response: () => "The model returned an empty response. Please try again.", + no_error_details: () => "LLM request failed with an unknown error.", + unclassified: () => "LLM request failed.", + unknown: () => "LLM request failed with an unknown error.", +} satisfies Record; + +function renderFailoverBaseCopy( + reason: FailoverReason, + context: FailoverUserCopyContext = {}, +): string | undefined { + return FAILOVER_REASON_BASE_COPY[reason](context); +} + +/** Render rate-limit versus overload copy from the canonical classified reason. */ +export function renderRateLimitOrOverloadedCopy(params: { + reason: Extract; + raw?: string; +}): string { + return ( + renderFailoverBaseCopy(params.reason, { raw: params.raw }) ?? RATE_LIMIT_ERROR_USER_MESSAGE + ); +} + +export function formatTransportErrorCopy(raw: string): string | undefined { + if (!raw || isCloudflareOrHtmlErrorPage(raw)) { + return undefined; + } + const lower = normalizeLowercaseStringOrEmpty(raw); + if ( + /\beconnrefused\b/i.test(raw) || + lower.includes("connection refused") || + lower.includes("actively refused") + ) { + return "LLM request failed: connection refused by the provider endpoint."; + } + if ( + /\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) || + lower.includes("socket hang up") || + lower.includes("connection reset") || + lower.includes("connection aborted") + ) { + return "LLM request failed: network connection was interrupted."; + } + if ( + /\benotfound\b|\beai_again\b/i.test(raw) || + lower.includes("getaddrinfo") || + lower.includes("no such host") || + lower.includes("dns") + ) { + return "LLM request failed: DNS lookup for the provider endpoint failed."; + } + if ( + /\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) || + lower.includes("network is unreachable") || + lower.includes("host is unreachable") + ) { + return "LLM request failed: the provider endpoint is unreachable from this host."; + } + if ( + lower.includes("fetch failed") || + lower.includes("connection error") || + lower.includes("network request failed") + ) { + return "LLM request failed: network connection error."; + } + if (raw.includes("网络错误") || raw.includes("网络异常") || raw.includes("连接错误")) { + return "LLM request failed: provider reported a network error."; + } + return undefined; +} + +export function formatDiskSpaceErrorCopy(raw: string): string | undefined { + const lower = normalizeLowercaseStringOrEmpty(raw); + return /\benospc\b/i.test(raw) || + lower.includes("no space left on device") || + lower.includes("disk full") + ? "OpenClaw could not write local session data because the disk is full. Free some disk space and try again." + : undefined; +} + +export function isInvalidStreamingEventOrderError(raw: string): boolean { + const lower = normalizeLowercaseStringOrEmpty(raw); + return ( + lower.includes("unexpected event order") && + lower.includes("message_start") && + lower.includes("message_stop") + ); +} + +export function isStreamingJsonParseError(raw: string): boolean { + return raw.trim() === MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE; +} + +export function getApiErrorPayloadFingerprint(raw?: string): string | null { + if (!raw) { + return null; + } + const payload = parseApiErrorPayload(raw); + return payload ? stableStringify(payload) : null; +} + +export function isRawApiErrorPayload(raw?: string): boolean { + return getApiErrorPayloadFingerprint(raw) !== null; +} + +/** Recognize provider HTTP/HTML failures from canonical classification facts. */ +export function isLikelyHttpErrorText(raw: string): boolean { + if (isCloudflareOrHtmlErrorPage(raw)) { + return true; + } + const status = extractLeadingHttpStatus(raw); + return Boolean( + status && + status.code >= 400 && + (classifyFailoverReason(raw) !== null || + classifyProviderRequestFacets({ status: status.code, message: raw }) !== null), + ); +} + +function shouldRewriteContextOverflowText(raw: string): boolean { + if (classifyFailoverReason(raw) !== "context_overflow") { + return false; + } + const status = extractLeadingHttpStatus(raw); + return ( + isRawApiErrorPayload(raw) || + Boolean(status && status.code >= 400) || + ERROR_PREFIX_RE.test(raw) || + CONTEXT_OVERFLOW_ERROR_HEAD_RE.test(raw) + ); +} + +function shouldRewriteRawPayloadWithoutErrorContext(raw: string): boolean { + if ( + raw.length > NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH || + !NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE.test(raw) + ) { + return false; + } + const info = parseApiErrorInfo(raw); + const normalizedType = normalizeLowercaseStringOrEmpty(info?.type); + if (normalizedType.endsWith("_error")) { + return true; + } + const code = Number(info?.httpCode); + return Number.isFinite(code) && code >= 400; +} + +/** Sanitize presentation text, then render error copy from classified facts when requested. */ +export function renderSanitizedUserFacingText( + sanitized: string, + opts?: { errorContext?: boolean }, +): string { + if (!sanitized) { + return sanitized; + } + const trimmed = sanitized.trim(); + if (!opts?.errorContext) { + return shouldRewriteRawPayloadWithoutErrorContext(trimmed) + ? formatRawAssistantErrorForUi(trimmed) + : sanitized; + } + const execDenied = formatExecDeniedUserMessage(trimmed); + if (execDenied) { + return execDenied; + } + const diskSpace = formatDiskSpaceErrorCopy(trimmed); + if (diskSpace) { + return diskSpace; + } + if (/incorrect role information|roles must alternate/i.test(trimmed)) { + return "Message ordering conflict - please try again. If this persists, use /new to start a fresh session."; + } + if (shouldRewriteContextOverflowText(trimmed)) { + return renderFailoverBaseCopy("context_overflow") ?? trimmed; + } + const reason = classifyFailoverReason(trimmed); + if (reason === "billing" || reason === "rate_limit" || reason === "overloaded") { + return renderFailoverBaseCopy(reason, { raw: trimmed }) ?? trimmed; + } + if (isGenericProviderInternalError(trimmed)) { + return formatRawAssistantErrorForUi(trimmed); + } + if (isInvalidStreamingEventOrderError(trimmed)) { + return "LLM request failed: provider returned an invalid streaming response. Please try again."; + } + const status = extractLeadingHttpStatus(trimmed); + if (isRawApiErrorPayload(trimmed) || (status && status.code >= 400 && reason)) { + return formatRawAssistantErrorForUi(trimmed); + } + if (isStreamingJsonParseError(trimmed)) { + return "LLM streaming response contained a malformed fragment. Please try again."; + } + if (ERROR_PREFIX_RE.test(trimmed)) { + const transport = formatTransportErrorCopy(trimmed); + if (transport) { + return transport; + } + if (isProviderCompletedErrorFinishReasonMessage(trimmed)) { + return formatRawAssistantErrorForUi(trimmed); + } + if (reason === "timeout") { + return renderFailoverBaseCopy("timeout") ?? trimmed; + } + return formatRawAssistantErrorForUi(trimmed); + } + return sanitized; +} + +export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT = + "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; +export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT = + "⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available."; +export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE = + "⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session."; +const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = + "⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again."; +const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = + "⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening."; +const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; +const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE = + "⚠️ The configured model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. This needs a config update (agents.defaults.model); retrying or starting a new session won't fix it."; + +const PROVIDER_REQUEST_COPY = { + "quota-429": PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE, + "conversation-state": PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, + "provider-internal": PROVIDER_INTERNAL_ERROR_USER_MESSAGE, + "provider-internal-503": PROVIDER_INTERNAL_ERROR_USER_MESSAGE, +} satisfies Record; + +function renderProviderRequestFailureCopy(params: { + classification: FailoverClassification | null; + facet: ProviderRequestFacet | null; + status?: number; +}): string | undefined { + const reason = + params.classification?.kind === "reason" ? params.classification.reason : undefined; + if (reason === "auth" && params.status === 401) { + return PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE; + } + if (reason === "model_not_found") { + return PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE; + } + return params.facet ? PROVIDER_REQUEST_COPY[params.facet] : undefined; +} + +type ProviderRequestErrorCode = + | "provider_authentication_error" + | "provider_conversation_state_error" + | "provider_internal_error" + | "provider_model_unavailable" + | "provider_rate_limit_or_quota_error"; + +export function resolveProviderRequestFailureCopy(params: { + classification: FailoverClassification | null; + facet: ProviderRequestFacet | null; + status?: number; + technicalMessage: string; +}) { + const userMessage = renderProviderRequestFailureCopy(params); + if (!userMessage) { + return undefined; + } + const reason = + params.classification?.kind === "reason" ? params.classification.reason : undefined; + const code: ProviderRequestErrorCode = + reason === "auth" && params.status === 401 + ? "provider_authentication_error" + : reason === "model_not_found" + ? "provider_model_unavailable" + : params.facet === "quota-429" + ? "provider_rate_limit_or_quota_error" + : params.facet === "conversation-state" + ? "provider_conversation_state_error" + : "provider_internal_error"; + return { + code, + userMessage, + technicalMessage: params.technicalMessage, + ...(params.facet === "provider-internal-503" ? { allowTransientHttpRetry: true as const } : {}), + }; +} + +export type ReplyFallbackAttempt = FallbackAttemptRecord & { authMode?: string }; + +function extractCodexUsageLimitErrorMessage( + attempts: readonly ReplyFallbackAttempt[], + directMessage: string, + directReason: FailoverReason | undefined, + directProvider: string | undefined, + sanitizeText?: (text: string) => string, +): string | undefined { + const attempt = attempts.find( + (candidate) => + candidate.provider === "openai" && candidate.reason === "rate_limit" && candidate.error, + ); + const text = + attempt?.error ?? + (directProvider === "openai" && directReason === "rate_limit" ? directMessage : undefined); + if (!text) { + return undefined; + } + const message = renderSanitizedUserFacingText(sanitizeText?.(text) ?? text, { + errorContext: true, + }) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .join(" ") + .trim(); + if (!message) { + return undefined; + } + const truncated = message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message; + return truncated.startsWith("⚠️") ? truncated : `⚠️ ${truncated}`; +} + +/** Render the reply surface's rate-limit copy, including structured cooldown context. */ +export function renderRateLimitReplyCopy(params: { + message: string; + reason?: FailoverReason; + provider?: string; + attempts?: readonly ReplyFallbackAttempt[]; + cooldownExpiry?: number | null; + nowMs?: number; + sanitizeText?: (text: string) => string; +}): string { + const attempts = params.attempts ?? []; + const usageLimit = extractCodexUsageLimitErrorMessage( + attempts, + params.message, + params.reason, + params.provider, + params.sanitizeText, + ); + if (usageLimit) { + return usageLimit; + } + if (attempts.some((attempt) => attempt.reason === "billing") || params.reason === "billing") { + return BILLING_ERROR_USER_MESSAGE; + } + if (attempts.length === 0) { + if (params.reason === "rate_limit" && isPeriodicUsageLimitErrorMessage(params.message)) { + const providerMessage = renderSanitizedUserFacingText( + params.sanitizeText?.(params.message) ?? params.message, + { errorContext: true }, + ); + return providerMessage.startsWith("⚠️") ? providerMessage : `⚠️ ${providerMessage}`; + } + return RATE_LIMIT_RETRY_MESSAGE; + } + const expiry = params.cooldownExpiry; + const nowMs = params.nowMs ?? Date.now(); + if (typeof expiry === "number" && expiry > nowMs) { + const secsLeft = Math.max(1, Math.ceil((expiry - nowMs) / 1000)); + return secsLeft <= 60 + ? `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.` + : `⚠️ Rate-limited — ready in ~${Math.ceil(secsLeft / 60)} min. Please try again shortly.`; + } + const attemptedModels = new Set( + attempts.map((attempt) => `${attempt.provider}/${attempt.model}`), + ); + return attemptedModels.size > 1 && + attempts.every((attempt) => attempt.reason === "rate_limit" || attempt.reason === "overloaded") + ? "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes." + : RATE_LIMIT_RETRY_MESSAGE; +} + +export function renderBillingReplyCopy(params: { + provider?: string; + model?: string; + authMode?: string; + attempts?: readonly ReplyFallbackAttempt[]; +}): string { + const attempts = params.attempts ?? []; + const billingFailure = + attempts.length > 0 + ? attempts.find( + (attempt) => + attempt.reason === "billing" && + (attempt.authMode === "oauth" || attempt.authMode === "token"), + ) + : params.authMode === "oauth" || params.authMode === "token" + ? params + : undefined; + return billingFailure && + (billingFailure.authMode === "oauth" || billingFailure.authMode === "token") + ? formatBillingErrorMessage( + billingFailure.provider, + billingFailure.model, + billingFailure.authMode, + ) + : BILLING_ERROR_USER_MESSAGE; +} + +const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]); + +export function renderMissingApiKeyReplyCopy(params?: { + provider: string; + providerGuidance?: boolean; +}): string | null { + const provider = params?.provider.trim().toLowerCase(); + if (!provider) { + return null; + } + if (provider === "openai" && params?.providerGuidance) { + return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-sol` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs."; + } + if (provider === "openai") { + return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.'; + } + return SAFE_MISSING_API_KEY_PROVIDERS.has(provider) + ? `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.` + : "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again."; +} + +const CLI_BACKEND_NO_OUTPUT_STALL_RE = + /\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu; +const CLI_BACKEND_OVERALL_TIMEOUT_RE = + /\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu; +const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu; + +export function renderCliTimeoutReplyCopy(params: { + message: string; + cliTimeout?: CliTimeoutContext; + provider?: string; + replayPrevented?: boolean; +}): string | null { + const stall = params.message.match(CLI_BACKEND_NO_OUTPUT_STALL_RE); + const overall = params.message.match(CLI_BACKEND_OVERALL_TIMEOUT_RE); + const timeout = params.cliTimeout; + const seconds = timeout?.timeoutSeconds ?? Number((stall ?? overall)?.[1]); + if (!Number.isFinite(seconds)) { + return null; + } + const routedModelRef = params.message.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1]; + const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : ""; + const mode = timeout?.mode ?? (stall ? "no-output" : "overall"); + const stoppedWork: string[] = []; + if (timeout?.backgroundTaskCount) { + stoppedWork.push( + `${timeout.backgroundTaskCount} CLI background ${timeout.backgroundTaskCount === 1 ? "task" : "tasks"}`, + ); + } + if (timeout?.activeToolCount) { + stoppedWork.push( + `${timeout.activeToolCount} active CLI tool ${timeout.activeToolCount === 1 ? "call" : "calls"}`, + ); + } + let workStatus = + stoppedWork.length > 0 + ? ` It also stopped ${stoppedWork.join(" and ")}; that work shares the parent CLI process. Effects may be partial; check before retrying.` + : timeout?.observedActivity + ? " The CLI had already begun work, so effects may be partial; check before retrying." + : ""; + if (params.replayPrevented) { + workStatus += " OpenClaw did not replay this turn automatically."; + } + return mode === "no-output" + ? `⚠️ CLI subprocess${routingSuffix}: no output for ${seconds}s, so the no-output watchdog stopped it. This is separate from the overall agent timeout; the gateway is unaffected.${workStatus} Check for an interactive prompt. The CLI backend ${params.provider ?? ""} produced no output before its watchdog expired.` + : `⚠️ CLI turn${routingSuffix}: timed out after ${seconds}s (overall turn limit). The gateway is unaffected.${workStatus} For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise \`agents.defaults.timeoutSeconds\`.`; +} + +type AuthProfileFailureCopyParams = { + reason: FailoverReason; + provider: string; + allInCooldown: boolean; + causeText?: string; + recoveryHint?: string; +}; + +const AUTH_PROFILE_COOLDOWN_COPY = { + auth: (provider: string) => + `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`, + auth_permanent: (provider: string) => `${provider} isn't accepting your saved login anymore.`, + format: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + rate_limit: (provider: string) => + `${provider} is asking us to slow down. Please wait a moment before trying again.`, + overloaded: (provider: string) => + `${provider} is overloaded right now. Please wait a moment before trying again.`, + billing: (provider: string) => + `${provider} rejected the request — looks like a billing issue on the account.`, + server_error: (provider: string) => + `${provider} is having issues right now. Please wait a moment before trying again.`, + timeout: (provider: string) => + `${provider} hasn't been responding. Please wait a moment before trying again.`, + tls_certificate: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + context_overflow: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + model_not_found: (provider: string) => `${provider} can't find the model you're using right now.`, + session_expired: (provider: string) => + `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`, + empty_response: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + no_error_details: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + unclassified: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, + unknown: (provider: string) => + `Couldn't reach ${provider} with any of your saved logins right now.`, +} satisfies Record string>; + +const AUTH_PROFILE_DIRECT_COPY: Partial string>> = { + auth: (provider) => + `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`, + auth_permanent: (provider) => `${provider} isn't accepting your saved login.`, + billing: (provider) => + `${provider} rejected the request — looks like a billing issue on the account.`, + session_expired: (provider) => + `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`, +}; + +function authProfileRecoveryApplies(reason: FailoverReason): boolean { + return ( + reason === "auth" || + reason === "auth_permanent" || + reason === "session_expired" || + reason === "billing" || + reason === "context_overflow" || + reason === "empty_response" || + reason === "no_error_details" || + reason === "unclassified" || + reason === "unknown" + ); +} + +export function renderAuthProfileFailoverCopy(params: AuthProfileFailureCopyParams): string { + const description = params.allInCooldown + ? AUTH_PROFILE_COOLDOWN_COPY[params.reason](params.provider) + : AUTH_PROFILE_DIRECT_COPY[params.reason]?.(params.provider); + if (!description) { + return params.causeText + ? params.causeText.trim() || + `Couldn't reach ${params.provider} with any of your saved logins right now.` + : `Couldn't reach ${params.provider} with any of your saved logins right now.`; + } + const hint = authProfileRecoveryApplies(params.reason) ? params.recoveryHint : null; + const causeText = params.causeText?.trim() ?? ""; + const suffix = causeText && !description.includes(causeText) ? ` (${causeText})` : ""; + return `${[description, hint].filter(Boolean).join(" ")}${suffix}`; +} + +const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal."; + +export function renderControlUiAgentFailureCopy(errorText: string): string { + return `⚠️ Agent failed before reply: ${errorText.trim().replace(/\.\s*$/, "")}.\n${CONTROL_UI_LOG_HINT}`; +} + +export function replaceGenericExternalRunFailureText(text: string): { + text: string; + replaced: boolean; +} { + if (text.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT) { + return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, replaced: true }; + } + const start = text.indexOf(GENERIC_EXTERNAL_RUN_FAILURE_TEXT); + if (start < 0 || text.slice(start + GENERIC_EXTERNAL_RUN_FAILURE_TEXT.length).trim()) { + return { text, replaced: false }; + } + const prefix = text.slice(0, start).trimEnd(); + return { + text: prefix + ? `${prefix} ${HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT}` + : HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, + replaced: true, + }; +} diff --git a/src/agents/tools/chat-history-text.ts b/src/agents/tools/chat-history-text.ts index cc528312fb14..9da90dbaeaff 100644 --- a/src/agents/tools/chat-history-text.ts +++ b/src/agents/tools/chat-history-text.ts @@ -6,6 +6,7 @@ import { extractAssistantTextForPhase } from "../../shared/chat-message-content.js"; import { sanitizeAssistantVisibleTextWithProfile } from "../../shared/text/assistant-visible-text.js"; import { sanitizeUserFacingText } from "../embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../embedded-agent-helpers/user-facing-text.js"; export function stripToolMessages(messages: unknown[]): unknown[] { return messages.filter((msg) => { @@ -47,5 +48,9 @@ export function extractAssistantText(message: unknown): string | undefined { // should not have its content rewritten with error templates (#13935). const errorContext = stopReason === "error"; - return joined ? sanitizeUserFacingText(joined, { errorContext }) : undefined; + return joined + ? errorContext + ? renderUserFacingText(joined, { errorContext: true }) + : sanitizeUserFacingText(joined) + : undefined; } diff --git a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts index 2d58a6a72aa0..686d3226002b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts @@ -16,6 +16,7 @@ import { withTempHome, } from "../../test/helpers/auto-reply/trigger-handling-test-harness.js"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import { renderControlUiAgentFailureCopy } from "../agents/failover/user-copy.js"; import { resolveSessionKey } from "../config/sessions.js"; import { loadExactSessionEntry, @@ -24,7 +25,6 @@ import { } from "../config/sessions/session-accessor.js"; import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js"; import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js"; -import { buildControlUiAgentFailureText } from "./reply/agent-runner-failure-copy.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; import type { MsgContext } from "./templating.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; @@ -65,7 +65,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ if (/context window exceeded/i.test(message)) { return "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."; } - return buildControlUiAgentFailureText(message); + return renderControlUiAgentFailureCopy(message); }; const stripHeartbeat = (text?: string) => { const trimmed = text?.trim(); @@ -361,7 +361,7 @@ describe("trigger handling", () => { for (const testCase of [ { error: "sandbox is not defined.", - expected: buildControlUiAgentFailureText("sandbox is not defined."), + expected: renderControlUiAgentFailureCopy("sandbox is not defined."), }, { error: "Context window exceeded", diff --git a/src/auto-reply/reply/agent-runner-error-handler.ts b/src/auto-reply/reply/agent-runner-error-handler.ts index 7840a9593067..1df5c6d8f65c 100644 --- a/src/auto-reply/reply/agent-runner-error-handler.ts +++ b/src/auto-reply/reply/agent-runner-error-handler.ts @@ -1,13 +1,21 @@ import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; import { classifyOAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js"; import { - formatRateLimitOrOverloadedErrorCopy, isCompactionFailureError, isLikelyContextOverflowError, isTransientHttpError, } from "../../agents/embedded-agent-helpers.js"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js"; import { isFailoverError } from "../../agents/failover-error.js"; +import { + GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, + renderBillingReplyCopy, + renderControlUiAgentFailureCopy, + renderRateLimitOrOverloadedCopy, + renderRateLimitReplyCopy, +} from "../../agents/failover/user-copy.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { AGENT_RUN_RESTART_ABORT_STOP_REASON, @@ -23,19 +31,12 @@ import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.j import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js"; import type { AgentTurnInternalResult, AgentTurnParams } from "./agent-runner-execution.types.js"; -import { - buildControlUiAgentFailureText, - GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, -} from "./agent-runner-failure-copy.js"; import { buildAuthProfileFailoverFailureText, buildExternalRunFailureReply, - buildRateLimitCooldownMessage, isNonDirectConversationContext, isVerboseFailureDetailEnabled, markAgentRunFailureReplyPayload, - resolveBillingFailureReplyText, resolveExternalRunFailureTextForConversation, resolveReplyFailoverFacts, } from "./agent-runner-failure-reply.js"; @@ -159,7 +160,7 @@ export async function handleAgentExecutionError(params: { ); takePendingLifecycleTerminal()?.emit("error", err); const switchErrorText = params.shouldSurfaceToControlUi - ? buildControlUiAgentFailureText( + ? renderControlUiAgentFailureCopy( "model switch could not be completed. The requested model may be temporarily unavailable.", ) : isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel) @@ -424,13 +425,16 @@ export async function handleAgentExecutionError(params: { ? isPureTransientSummary : failoverReason === "rate_limit" || failoverReason === "overloaded"; const rateLimitOrOverloadedCopy = - !hasFallbackAttempts || isPureTransientSummary - ? formatRateLimitOrOverloadedErrorCopy( - isFailoverError(err) && failoverReason === "overloaded" ? "overloaded" : message, - ) + (!hasFallbackAttempts && + (failoverReason === "rate_limit" || failoverReason === "overloaded")) || + isPureTransientSummary + ? renderRateLimitOrOverloadedCopy({ + reason: isOverloaded ? "overloaded" : "rate_limit", + raw: message, + }) : undefined; const userFacingMessage = isTransientHttp - ? sanitizeUserFacingText(message, { errorContext: true }) + ? renderUserFacingText(message, { errorContext: true }) : message; const externalRunFailureReply = !isBilling && @@ -450,15 +454,27 @@ export async function handleAgentExecutionError(params: { ) : undefined; const fallbackText = isBilling - ? resolveBillingFailureReplyText(err) + ? renderBillingReplyCopy({ + attempts: fallbackAttempts, + ...(isFailoverError(err) + ? { provider: err.provider, model: err.model, authMode: err.authMode } + : {}), + }) : isRateLimit && !isOverloaded - ? buildRateLimitCooldownMessage(err) + ? renderRateLimitReplyCopy({ + message, + reason: failoverReason, + attempts: fallbackAttempts, + provider: isFailoverError(err) ? err.provider : undefined, + cooldownExpiry: isFailoverError(err) ? err.soonestCooldownExpiry : undefined, + sanitizeText: (text) => sanitizeUserFacingText(text, { errorContext: true }), + }) : rateLimitOrOverloadedCopy ? rateLimitOrOverloadedCopy : isContextOverflow ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." : params.shouldSurfaceToControlUi - ? buildControlUiAgentFailureText(userFacingMessage) + ? renderControlUiAgentFailureCopy(userFacingMessage) : (externalRunFailureReply?.text ?? (turn.isHeartbeat ? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT diff --git a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts index 42111f9be3f4..830ebd40e460 100644 --- a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, @@ -6,7 +7,6 @@ import { createMockTypingSignaler, createFollowupRun, } from "./agent-runner-execution.test-support.js"; -import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./agent-runner-failure-copy.js"; const state = setupAgentRunnerExecutionTestState(); diff --git a/src/auto-reply/reply/agent-runner-execution-probes.test.ts b/src/auto-reply/reply/agent-runner-execution-probes.test.ts index 86e406dff7cd..68eb8832a0fe 100644 --- a/src/auto-reply/reply/agent-runner-execution-probes.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-probes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { FailoverError } from "../../agents/failover-error.js"; +import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "../../agents/failover/user-copy.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import type { SessionEntry } from "../../config/sessions.js"; import { resolveFallbackCandidateRun } from "./agent-runner-auth-profile.js"; @@ -18,7 +19,6 @@ import type { FallbackRunnerParams, EmbeddedAgentParams, } from "./agent-runner-execution.test-support.js"; -import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js"; const state = setupAgentRunnerExecutionTestState(); diff --git a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts index 91093bacaeef..4bfcebbbfd2c 100644 --- a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { formatBillingErrorMessage } from "../../agents/embedded-agent-helpers.js"; import { FailoverError } from "../../agents/failover-error.js"; +import { BILLING_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js"; import { ProviderAuthError } from "../../agents/model-auth.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import type { TemplateContext } from "../templating.js"; @@ -880,7 +881,7 @@ describe("executeAgentTurn: provider failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toBe("billing"); + expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE); expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT); } }); diff --git a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts index 637efe6832d2..dbaeec9b7c90 100644 --- a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { FailoverError } from "../../agents/failover-error.js"; +import { + BILLING_ERROR_USER_MESSAGE, + HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, +} from "../../agents/failover/user-copy.js"; import { AgentHarnessSessionSupersededError } from "../../agents/harness/errors.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; @@ -19,7 +23,6 @@ import { createMinimalRunAgentTurnParams, createTestFallbackSummaryError, } from "./agent-runner-execution.test-support.js"; -import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js"; import { buildKnownAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js"; const state = setupAgentRunnerExecutionTestState(); @@ -64,7 +67,7 @@ describe("executeAgentTurn: terminal failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toBe("billing"); + expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE); expect(result.payload.text).not.toContain("All models failed"); expect(result.payload.text).not.toContain("402 (billing)"); expect(result.payload.text).not.toContain("Rate-limited"); @@ -216,7 +219,7 @@ describe("executeAgentTurn: terminal failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toBe("billing"); + expect(result.payload.text).toBe(BILLING_ERROR_USER_MESSAGE); } }); diff --git a/src/auto-reply/reply/agent-runner-execution.test-support.ts b/src/auto-reply/reply/agent-runner-execution.test-support.ts index ce5d483a9b47..1493bb321f35 100644 --- a/src/auto-reply/reply/agent-runner-execution.test-support.ts +++ b/src/auto-reply/reply/agent-runner-execution.test-support.ts @@ -1,10 +1,10 @@ // Shared mocks and fixtures for agent-runner execution tests. import { afterEach, beforeEach, expect, vi } from "vitest"; import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js"; -import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/embedded-agent-helpers/error-text.js"; import type { runEmbeddedAgentEntry } from "../../agents/embedded-agent-runner/run-entry.js"; import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js"; import { FailoverError, type FallbackAttemptRecord } from "../../agents/failover-error.js"; +import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/failover/user-copy.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { createUserTurnTranscriptRecorder, @@ -171,7 +171,6 @@ vi.mock("../../agents/embedded-agent-helpers.js", async () => { ); return { ...actual, - BILLING_ERROR_USER_MESSAGE: "billing", formatBillingErrorMessage: actual.formatBillingErrorMessage, isCompactionFailureError: (message?: string) => state.isCompactionFailureErrorMock(message), isContextOverflowError: (message?: string) => state.isContextOverflowErrorMock(message), diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 8ff75d9d437f..a36387212040 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -9,12 +9,13 @@ import type { ChatRunStartupPhase } from "../../../packages/gateway-protocol/src import { peekSessionMcpRuntime } from "../../agents/agent-bundle-mcp-manager-api.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { - formatRateLimitOrOverloadedErrorCopy, + classifyFailoverReason, isContextOverflowError, } from "../../agents/embedded-agent-helpers.js"; import type { EmbeddedAgentExecutionPhase } from "../../agents/embedded-agent-runner/execution-phase.js"; import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js"; import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; +import { renderRateLimitOrOverloadedCopy } from "../../agents/failover/user-copy.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { leaseMcpAppModelContextForTurn } from "../../agents/mcp-app-model-context.js"; import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js"; @@ -421,9 +422,11 @@ async function executeAgentTurnInternalWithRetryState( (p) => p.isError && hasNonEmptyString(p.text) && !p.text.startsWith("⚠️"), )?.text ?? ""; const errorCandidate = metaErrorMsg || rawErrorPayloadText; - const formattedErrorCandidate = errorCandidate - ? formatRateLimitOrOverloadedErrorCopy(errorCandidate) - : undefined; + const candidateReason = errorCandidate ? classifyFailoverReason(errorCandidate) : null; + const formattedErrorCandidate = + candidateReason === "rate_limit" || candidateReason === "overloaded" + ? renderRateLimitOrOverloadedCopy({ reason: candidateReason, raw: errorCandidate }) + : undefined; if (formattedErrorCandidate) { runResult.payloads = [ markAgentRunFailureReplyPayload({ diff --git a/src/auto-reply/reply/agent-runner-failure-copy.ts b/src/auto-reply/reply/agent-runner-failure-copy.ts deleted file mode 100644 index 046e090e1490..000000000000 --- a/src/auto-reply/reply/agent-runner-failure-copy.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Centralizes user-facing failure copy for external agent runner errors. -import { AUTH_INVALID_TOKEN_USER_TEXT } from "../../agents/embedded-agent-helpers/error-text.js"; - -export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT = - "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; - -export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT = - "⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available."; - -export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE = - "⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session."; - -export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = - "⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again."; - -export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = - "⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening."; - -export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; - -export const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE = - "⚠️ The configured model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. This needs a config update (agents.defaults.model); retrying or starting a new session won't fix it."; - -const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal."; - -/** Preserves raw errors on internal surfaces while making the log command actionable. */ -export function buildControlUiAgentFailureText(errorText: string): string { - const trimmedError = errorText.trim().replace(/\.\s*$/, ""); - return `⚠️ Agent failed before reply: ${trimmedError}.\n${CONTROL_UI_LOG_HINT}`; -} - -/** True when text is exactly the generic external run failure copy. */ -function isGenericExternalRunFailureText(text: string | undefined): boolean { - return text?.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT; -} - -/** Replaces trailing generic failure text with heartbeat-specific copy. */ -export function replaceGenericExternalRunFailureText(text: string): { - text: string; - replaced: boolean; -} { - if (isGenericExternalRunFailureText(text)) { - return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, replaced: true }; - } - - const genericStart = text.indexOf(GENERIC_EXTERNAL_RUN_FAILURE_TEXT); - if (genericStart < 0) { - return { text, replaced: false }; - } - - const trailing = text.slice(genericStart + GENERIC_EXTERNAL_RUN_FAILURE_TEXT.length).trim(); - if (trailing) { - return { text, replaced: false }; - } - - const prefix = text.slice(0, genericStart).trimEnd(); - return { - text: prefix - ? `${prefix} ${HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT}` - : HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, - replaced: true, - }; -} diff --git a/src/auto-reply/reply/agent-runner-failure-reply.ts b/src/auto-reply/reply/agent-runner-failure-reply.ts index 12ad02e22fd2..77e10ed72b66 100644 --- a/src/auto-reply/reply/agent-runner-failure-reply.ts +++ b/src/auto-reply/reply/agent-runner-failure-reply.ts @@ -1,32 +1,34 @@ import { expectDefined } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { formatAuthProfileFailureMessage } from "../../agents/auth-profiles/failure-copy.js"; import { buildOAuthRefreshFailureLoginCommand, classifyOAuthRefreshFailureError, formatOAuthRefreshFailureLoginCommandMarkdown, } from "../../agents/auth-profiles/oauth-refresh-failure.js"; -import { - BILLING_ERROR_USER_MESSAGE, - formatBillingErrorMessage, - formatRateLimitOrOverloadedErrorCopy, -} from "../../agents/embedded-agent-helpers.js"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js"; import { describeFailoverError, findCliMaxTurnsError, findCliTimeoutError, isFailoverError, - type FallbackAttemptRecord, } from "../../agents/failover-error.js"; -import { isPeriodicUsageLimitErrorMessage } from "../../agents/failover/classify.js"; +import { classifyProviderRequestFacets } from "../../agents/failover/request-error-facets.js"; import { - classifyProviderRequestFacets, - type ProviderRequestFacet, -} from "../../agents/failover/request-error-facets.js"; -import type { FailoverClassification } from "../../agents/failover/signal.js"; + GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, + renderAuthProfileFailoverCopy, + renderBillingReplyCopy, + renderCliTimeoutReplyCopy, + renderMissingApiKeyReplyCopy, + renderRateLimitOrOverloadedCopy, + renderRateLimitReplyCopy, + resolveProviderRequestFailureCopy, + type ReplyFallbackAttempt, +} from "../../agents/failover/user-copy.js"; import { isProviderAuthError } from "../../agents/model-auth-runtime-shared.js"; +import { buildProviderAuthRecoveryHint } from "../../agents/provider-auth-recovery-hint.js"; import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -35,18 +37,6 @@ import type { TemplateContext } from "../templating.js"; import type { VerboseLevel } from "../thinking.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; -import { - GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, - PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, - PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, - PROVIDER_INTERNAL_ERROR_USER_MESSAGE, - PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE, - PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE, -} from "./agent-runner-failure-copy.js"; - -const RATE_LIMIT_RETRY_MESSAGE = - "⚠️ The model request was rate-limited. Please try again in a few minutes."; export function resolveReplyFailoverFacts(error: unknown, message: string) { const described = describeFailoverError(error); @@ -55,7 +45,7 @@ export function resolveReplyFailoverFacts(error: unknown, message: string) { : null; return { reason: classification?.kind === "reason" ? classification.reason : undefined, - providerRequestError: mapProviderRequestError({ + providerRequestError: resolveProviderRequestFailureCopy({ classification, facet: classifyProviderRequestFacets({ status: described.status, @@ -69,154 +59,9 @@ export function resolveReplyFailoverFacts(error: unknown, message: string) { type ReplyFailoverFacts = ReturnType; -type ProviderRequestErrorCode = - | "provider_authentication_error" - | "provider_conversation_state_error" - | "provider_internal_error" - | "provider_model_unavailable" - | "provider_rate_limit_or_quota_error"; - -function mapProviderRequestError(params: { - classification: FailoverClassification | null; - facet: ProviderRequestFacet | null; - status?: number; - technicalMessage: string; -}) { - const reason = - params.classification?.kind === "reason" ? params.classification.reason : undefined; - const mapped: [ProviderRequestErrorCode, string, true?] | undefined = - reason === "auth" && params.status === 401 - ? ["provider_authentication_error", PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE] - : reason === "model_not_found" - ? ["provider_model_unavailable", PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE] - : params.facet === "quota-429" - ? ["provider_rate_limit_or_quota_error", PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE] - : params.facet === "conversation-state" - ? ["provider_conversation_state_error", PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE] - : params.facet === "provider-internal" || params.facet === "provider-internal-503" - ? [ - "provider_internal_error", - PROVIDER_INTERNAL_ERROR_USER_MESSAGE, - params.facet === "provider-internal-503" || undefined, - ] - : undefined; - return mapped - ? { - code: mapped[0], - userMessage: mapped[1], - technicalMessage: params.technicalMessage, - ...(mapped[2] ? { allowTransientHttpRetry: true as const } : {}), - } - : undefined; -} - -/** Builds a human-friendly rate-limit message, including a known cooldown. */ -export function buildRateLimitCooldownMessage(err: unknown): string { - const message = formatErrorMessage(err); - const failoverFacts = resolveReplyFailoverFacts(err, message); - const attempts = readFallbackAttempts(err); - const codexUsageLimitMessage = extractCodexUsageLimitErrorMessage( - attempts, - message, - failoverFacts.reason, - isFailoverError(err) ? err.provider : undefined, - ); - if (codexUsageLimitMessage) { - return codexUsageLimitMessage; - } - if (attempts.some((attempt) => attempt.reason === "billing")) { - return BILLING_ERROR_USER_MESSAGE; - } - if (failoverFacts.reason === "billing") { - return BILLING_ERROR_USER_MESSAGE; - } - if (attempts.length === 0) { - if (failoverFacts.reason === "rate_limit" && isPeriodicUsageLimitErrorMessage(message)) { - const providerMessage = sanitizeUserFacingText(message, { errorContext: true }); - return providerMessage.startsWith("⚠️") ? providerMessage : `⚠️ ${providerMessage}`; - } - return RATE_LIMIT_RETRY_MESSAGE; - } - const expiry = isFailoverError(err) ? err.soonestCooldownExpiry : undefined; - const now = Date.now(); - if (typeof expiry === "number" && expiry > now) { - const secsLeft = Math.max(1, Math.ceil((expiry - now) / 1000)); - if (secsLeft <= 60) { - return `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.`; - } - return `⚠️ Rate-limited — ready in ~${Math.ceil(secsLeft / 60)} min. Please try again shortly.`; - } - const attemptedModels = new Set( - attempts.map((attempt) => `${attempt.provider}/${attempt.model}`), - ); - if ( - attemptedModels.size > 1 && - attempts.every((attempt) => attempt.reason === "rate_limit" || attempt.reason === "overloaded") - ) { - return "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes."; - } - return RATE_LIMIT_RETRY_MESSAGE; -} - -export function resolveBillingFailureReplyText(err: unknown): string { - const attempts = readFallbackAttempts(err); - const billingFailure = - attempts.length > 0 - ? attempts.find( - (attempt) => - attempt.reason === "billing" && - (attempt.authMode === "oauth" || attempt.authMode === "token"), - ) - : isFailoverError(err) && err.reason === "billing" - ? err - : undefined; - if ( - !billingFailure || - (billingFailure.authMode !== "oauth" && billingFailure.authMode !== "token") - ) { - return BILLING_ERROR_USER_MESSAGE; - } - return formatBillingErrorMessage( - billingFailure.provider, - billingFailure.model, - billingFailure.authMode, - ); -} - -function extractCodexUsageLimitErrorMessage( - attempts: readonly ReplyFallbackAttempt[], - directMessage: string, - directReason: ReplyFailoverFacts["reason"], - directProvider: string | undefined, -): string | undefined { - const attempt = attempts.find( - (candidate) => - candidate.provider === "openai" && candidate.reason === "rate_limit" && candidate.error, - ); - const text = - attempt?.error ?? - (directProvider === "openai" && directReason === "rate_limit" ? directMessage : undefined); - if (!text) { - return undefined; - } - const message = sanitizeUserFacingText(text, { errorContext: true }) - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean) - .join(" ") - .trim(); - if (!message) { - return undefined; - } - const truncated = message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message; - return truncated.startsWith("⚠️") ? truncated : `⚠️ ${truncated}`; -} - -type ReplyFallbackAttempt = FallbackAttemptRecord & { authMode?: string }; - -function readFallbackAttempts(err: unknown): readonly ReplyFallbackAttempt[] { - return isFailoverError(err) && Array.isArray(err.attempts) - ? (err.attempts as readonly ReplyFallbackAttempt[]) +function readFallbackAttempts(error: unknown): readonly ReplyFallbackAttempt[] { + return isFailoverError(error) && Array.isArray(error.attempts) + ? (error.attempts as readonly ReplyFallbackAttempt[]) : []; } @@ -231,7 +76,6 @@ function collapseRepeatedFailureDetail(message: string): string { return message.trim(); } -const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]); const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900; const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:"; const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:"; @@ -278,11 +122,6 @@ export function resolveExternalRunFailureTextForConversation(params: { return silentPolicy === "disallow" ? params.text : SILENT_REPLY_TOKEN; } -const CLI_BACKEND_NO_OUTPUT_STALL_RE = - /\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu; -const CLI_BACKEND_OVERALL_TIMEOUT_RE = - /\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu; -const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu; const CODEX_APP_SERVER_CLIENT_CLOSED_BEFORE_REPLY_RE = /\bcodex app-server client closed before turn completed\b/iu; const CODEX_APP_SERVER_TURN_COMPLETION_IDLE_TIMEOUT_RE = @@ -313,7 +152,7 @@ export function buildPreflightCompactionFailureText( if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) { return null; } - const reason = sanitizeUserFacingText( + const reason = renderUserFacingText( normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length), { errorContext: true }, ) @@ -326,89 +165,21 @@ export function buildPreflightCompactionFailureText( ); } -function buildCliBackendTimeoutFailureText(input: { - message: string; - error?: unknown; - replayPrevented?: boolean; -}): string | null { - const normalizedMessage = collapseRepeatedFailureDetail(input.message); - const cliTimeoutError = findCliTimeoutError(input.error); - const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE); - const overall = normalizedMessage.match(CLI_BACKEND_OVERALL_TIMEOUT_RE); - const timeout = cliTimeoutError?.cliTimeout; - const seconds = timeout?.timeoutSeconds ?? Number((stall ?? overall)?.[1]); - if (!Number.isFinite(seconds)) { - return null; - } - const routedModelRef = normalizedMessage.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1]; - const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : ""; - const mode = timeout?.mode ?? (stall ? "no-output" : "overall"); - let workStatus = ""; - const stoppedWork: string[] = []; - if (timeout?.backgroundTaskCount) { - const noun = timeout.backgroundTaskCount === 1 ? "task" : "tasks"; - stoppedWork.push(`${timeout.backgroundTaskCount} CLI background ${noun}`); - } - if (timeout?.activeToolCount) { - const noun = timeout.activeToolCount === 1 ? "call" : "calls"; - stoppedWork.push(`${timeout.activeToolCount} active CLI tool ${noun}`); - } - if (stoppedWork.length > 0) { - workStatus = ` It also stopped ${stoppedWork.join(" and ")}; that work shares the parent CLI process. Effects may be partial; check before retrying.`; - } else if (timeout?.observedActivity) { - workStatus = - " The CLI had already begun work, so effects may be partial; check before retrying."; - } - if (input.replayPrevented) { - workStatus += " OpenClaw did not replay this turn automatically."; - } - if (mode === "no-output") { - const backendId = cliTimeoutError?.provider ?? ""; - return ( - `⚠️ CLI subprocess${routingSuffix}: no output for ${seconds}s, so the no-output watchdog stopped it. ` + - `This is separate from the overall agent timeout; the gateway is unaffected.${workStatus} ` + - "Check for an interactive prompt. " + - `The CLI backend ${backendId} produced no output before its watchdog expired.` - ); - } - return ( - `⚠️ CLI turn${routingSuffix}: timed out after ${seconds}s (overall turn limit). The gateway is unaffected.${workStatus} ` + - "For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise `agents.defaults.timeoutSeconds`." - ); -} - -function buildMissingApiKeyFailureText(input: { message: string; error?: unknown }): string | null { - const authError = isProviderAuthError(input.error) ? input.error : undefined; - const provider = authError?.provider.trim().toLowerCase(); - if (!provider) { - return null; - } - if (provider === "openai" && authError?.providerGuidance) { - return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.6-sol` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs."; - } - if (provider === "openai") { - return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.'; - } - if (SAFE_MISSING_API_KEY_PROVIDERS.has(provider)) { - return `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`; - } - return "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again."; -} - export function buildAuthProfileFailoverFailureText(error: unknown): string | null { if (!isFailoverError(error) || !error.provider || !error.authProfileFailure) { return null; } - return formatAuthProfileFailureMessage({ + return renderAuthProfileFailoverCopy({ reason: error.reason, provider: error.provider, allInCooldown: error.authProfileFailure.allInCooldown, - cause: error.cause, + causeText: error.cause ? formatErrorMessage(error.cause).trim() : undefined, + recoveryHint: buildProviderAuthRecoveryHint({ provider: error.provider }), }); } function formatForwardedExternalRunFailureText(message: string): string { - const sanitized = sanitizeUserFacingText(message, { errorContext: true }) + const sanitized = renderUserFacingText(message, { errorContext: true }) .trim() .replace(/^⚠️\s*/u, "") .replace(/\s+/gu, " "); @@ -478,13 +249,15 @@ export function buildExternalRunFailureReply( const cliMaxTurnsError = findCliMaxTurnsError(error); if (cliMaxTurnsError) { return { - text: sanitizeUserFacingText(cliMaxTurnsError.message, { errorContext: true }), + text: renderUserFacingText(cliMaxTurnsError.message, { errorContext: true }), isGenericRunnerFailure: false, }; } - const cliBackendTimeoutFailure = buildCliBackendTimeoutFailureText({ + const cliTimeoutError = findCliTimeoutError(error); + const cliBackendTimeoutFailure = renderCliTimeoutReplyCopy({ message: normalizedMessage, - error, + cliTimeout: cliTimeoutError?.cliTimeout, + provider: cliTimeoutError?.provider, replayPrevented: options?.replayPrevented, }); if (cliBackendTimeoutFailure) { @@ -494,10 +267,12 @@ export function buildExternalRunFailureReply( if (providerRequestError) { return { text: providerRequestError.userMessage, isGenericRunnerFailure: false }; } - const missingApiKeyFailure = buildMissingApiKeyFailureText({ - message: normalizedMessage, - error, - }); + const authError = isProviderAuthError(error) ? error : undefined; + const missingApiKeyFailure = renderMissingApiKeyReplyCopy( + authError + ? { provider: authError.provider, providerGuidance: authError.providerGuidance } + : undefined, + ); if (missingApiKeyFailure) { return { text: missingApiKeyFailure, isGenericRunnerFailure: false }; } @@ -598,7 +373,16 @@ export function buildKnownAgentRunFailureReplyPayload(params: { if (isBilling) { return markAgentRunFailureReplyPayload({ text: resolveExternalRunFailureTextForConversation({ - text: resolveBillingFailureReplyText(params.err), + text: renderBillingReplyCopy({ + attempts: fallbackAttempts, + ...(isFailoverError(params.err) + ? { + provider: params.err.provider, + model: params.err.model, + authMode: params.err.authMode, + } + : {}), + }), sessionCtx: params.sessionCtx, isGenericRunnerFailure: false, cfg: params.cfg, @@ -633,16 +417,28 @@ export function buildKnownAgentRunFailureReplyPayload(params: { ? isPureTransientSummary : failoverReason === "rate_limit" || failoverReason === "overloaded"; const rateLimitOrOverloadedCopy = - !hasFallbackAttempts || isPureTransientSummary - ? formatRateLimitOrOverloadedErrorCopy( - isFailoverError(params.err) && failoverReason === "overloaded" ? "overloaded" : message, - ) + (!hasFallbackAttempts && + (failoverReason === "rate_limit" || failoverReason === "overloaded")) || + isPureTransientSummary + ? renderRateLimitOrOverloadedCopy({ + reason: isOverloaded ? "overloaded" : "rate_limit", + raw: message, + }) : undefined; if (isRateLimit && !isOverloaded) { return markAgentRunFailureReplyPayload({ text: resolveExternalRunFailureTextForConversation({ - text: buildRateLimitCooldownMessage(params.err), + text: renderRateLimitReplyCopy({ + message, + reason: failoverReason, + attempts: fallbackAttempts, + provider: isFailoverError(params.err) ? params.err.provider : undefined, + cooldownExpiry: isFailoverError(params.err) + ? params.err.soonestCooldownExpiry + : undefined, + sanitizeText: (text) => sanitizeUserFacingText(text, { errorContext: true }), + }), sessionCtx: params.sessionCtx, isGenericRunnerFailure: false, cfg: params.cfg, diff --git a/src/auto-reply/reply/agent-runner-fallback-settlement.ts b/src/auto-reply/reply/agent-runner-fallback-settlement.ts index 6ab6acfa614b..8fc22796de8b 100644 --- a/src/auto-reply/reply/agent-runner-fallback-settlement.ts +++ b/src/auto-reply/reply/agent-runner-fallback-settlement.ts @@ -1,4 +1,8 @@ import { isContextOverflowError } from "../../agents/embedded-agent-helpers.js"; +import { + PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, + renderControlUiAgentFailureCopy, +} from "../../agents/failover/user-copy.js"; import { createAgentRunRestartAbortError, isAgentRunRestartAbortReason, @@ -9,10 +13,6 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js"; -import { - buildControlUiAgentFailureText, - PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, -} from "./agent-runner-failure-copy.js"; import { markAgentRunFailureReplyPayload } from "./agent-runner-failure-reply.js"; import type { AgentFallbackCandidatesResult } from "./agent-runner-fallback-candidate.js"; import type { @@ -119,7 +119,7 @@ export async function settleAgentFallbackCycle(params: { kind: "final", payload: markAgentRunFailureReplyPayload({ text: cycle.shouldSurfaceToControlUi - ? buildControlUiAgentFailureText(embeddedErrorText) + ? renderControlUiAgentFailureCopy(embeddedErrorText) : PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE, }), }; diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index b82c3d312545..ecc1b1a852f6 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -4,6 +4,7 @@ import { resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js"; import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; import type { ReplyToMode } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -125,7 +126,9 @@ function sanitizeFinalReplyText( if (!text) { return text; } - return sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); + return payload.isError + ? renderUserFacingText(text, { errorContext: true }) + : sanitizeUserFacingText(text); } function sanitizeHeartbeatPayload(payload: ReplyPayload): ReplyPayload { diff --git a/src/auto-reply/reply/agent-runner-presentation.ts b/src/auto-reply/reply/agent-runner-presentation.ts index 09caf726cb9b..edec18681634 100644 --- a/src/auto-reply/reply/agent-runner-presentation.ts +++ b/src/auto-reply/reply/agent-runner-presentation.ts @@ -1,5 +1,6 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js"; import { logVerbose } from "../../globals.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { @@ -79,7 +80,9 @@ export function createAgentTurnPresentation(params: { if (!text) { return { skip: true }; } - const sanitized = sanitizeUserFacingText(text, { errorContext }); + const sanitized = errorContext + ? renderUserFacingText(text, { errorContext: true }) + : sanitizeUserFacingText(text); return sanitized.trim() ? { text: sanitized, skip: false } : { skip: true }; }; diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 392d622bd317..75984d60893c 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { + GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, +} from "../../agents/failover/user-copy.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { @@ -21,10 +25,6 @@ import { import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js"; import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; import type { TemplateContext } from "../templating.js"; -import { - GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, -} from "./agent-runner-failure-copy.js"; import type { InternalGetReplyOptions } from "./get-reply.types.js"; import { clearSessionQueues, diff --git a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts index f7a0a45c0af4..b0e976753b74 100644 --- a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts @@ -1,12 +1,12 @@ // Imported by dispatch-from-config.test.ts to keep its mocked suite in one Vitest module graph. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "../../agents/failover/user-copy.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./agent-runner-failure-copy.js"; import { NO_VISIBLE_REPLY_FALLBACK_TEXT, QUEUE_CAP_REJECTION_TEXT, diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index e4324d69bfe9..666038396597 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,6 +1,7 @@ // Normalizes raw agent output into sendable reply text and metadata. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../../agents/embedded-agent-helpers/user-facing-text.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { copyReplyPayloadMetadata } from "../reply-payload.js"; @@ -102,7 +103,9 @@ export function normalizeReplyPayload( } if (text) { - text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); + text = payload.isError + ? renderUserFacingText(text, { errorContext: true }) + : sanitizeUserFacingText(text); } if (!hasContent(text)) { opts.onSkip?.("empty"); diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 17227f105f88..4a2367fe381c 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -9,7 +9,7 @@ import { classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../agents/agent-run-terminal-outcome.js"; -import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js"; import { isMainSessionRecoveryLifecycleEvent, projectMainSessionRecoveryLifecycle, @@ -96,7 +96,7 @@ function resolveSessionRunError( if ((status !== "failed" && status !== "timeout") || !outcome.error) { return undefined; } - const sanitized = sanitizeUserFacingText(outcome.error, { errorContext: true }) + const sanitized = renderUserFacingText(outcome.error, { errorContext: true }) .replace(/\s+/g, " ") .trim(); return sanitized ? truncateUtf16Safe(sanitized, SESSION_RUN_ERROR_MAX_CHARS) : undefined; diff --git a/src/infra/heartbeat-runner-delivery.ts b/src/infra/heartbeat-runner-delivery.ts index d5df109950be..4e3679d4704c 100644 --- a/src/infra/heartbeat-runner-delivery.ts +++ b/src/infra/heartbeat-runner-delivery.ts @@ -2,8 +2,8 @@ import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; +import { replaceGenericExternalRunFailureText } from "../agents/failover/user-copy.js"; import { copyReplyPayloadMetadata, getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; -import { replaceGenericExternalRunFailureText } from "../auto-reply/reply/agent-runner-failure-copy.js"; import { buildRecoverablePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js"; import { sendDurableMessageBatch } from "../channels/message/runtime.js"; import { patchSessionEntry } from "../config/sessions/session-accessor.js"; diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts index f5d8a9452fe8..b0b84c07243b 100644 --- a/src/infra/heartbeat-runner.tool-response.test.ts +++ b/src/infra/heartbeat-runner.tool-response.test.ts @@ -1,5 +1,9 @@ // Covers heartbeat tool-response handling and visible reply policy. import { afterEach, describe, expect, it, vi } from "vitest"; +import { + GENERIC_EXTERNAL_RUN_FAILURE_TEXT, + HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, +} from "../agents/failover/user-copy.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { createHeartbeatToolResponsePayload, @@ -9,10 +13,6 @@ import { markReplyPayloadForSourceSuppressionDelivery, setReplyPayloadMetadata, } from "../auto-reply/reply-payload.js"; -import { - GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, -} from "../auto-reply/reply/agent-runner-failure-copy.js"; import type { OpenClawConfig } from "../config/config.js"; import { patchSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; diff --git a/src/plugin-sdk/qa-channel-protocol.ts b/src/plugin-sdk/qa-channel-protocol.ts index a315a1bd47ab..7e6e2de92eba 100644 --- a/src/plugin-sdk/qa-channel-protocol.ts +++ b/src/plugin-sdk/qa-channel-protocol.ts @@ -122,6 +122,8 @@ export type QaBusMessage = { senderId: string; senderName?: string; text: string; + /** Runtime-authored failure marker; copy wording is not a QA contract. */ + isError?: boolean; timestamp: number; threadId?: string; threadTitle?: string; @@ -187,6 +189,8 @@ export type QaBusOutboundMessageInput = { senderId?: string; senderName?: string; text: string; + /** Preserves ReplyPayload.isError through the synthetic channel transport. */ + isError?: boolean; timestamp?: number; threadId?: string; replyToId?: string; diff --git a/src/sessions/session-agent-status.ts b/src/sessions/session-agent-status.ts index 7a3c5f8fbee3..fe9de8f871b5 100644 --- a/src/sessions/session-agent-status.ts +++ b/src/sessions/session-agent-status.ts @@ -4,7 +4,7 @@ import { type SessionAgentAttentionIconId, type SessionAgentStatus, } from "../../packages/gateway-protocol/src/session-agent-status.js"; -import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js"; const SESSION_AGENT_STATUS_NOTE_MAX_CHARS = 120; const SESSION_AGENT_STATUS_DEFAULT_TTL_MINUTES = 30; @@ -19,7 +19,7 @@ export function isSessionAgentAttentionIconId( } export function sanitizeSessionAgentStatusNote(value: string): string { - const normalized = sanitizeUserFacingText(value, { errorContext: true }) + const normalized = renderUserFacingText(value, { errorContext: true }) .replace(/\s+/g, " ") .trim(); return truncateUtf16Safe(normalized, SESSION_AGENT_STATUS_NOTE_MAX_CHARS).trimEnd(); diff --git a/src/tasks/task-status.ts b/src/tasks/task-status.ts index a077f60fd0d5..8e12c3ddcbe9 100644 --- a/src/tasks/task-status.ts +++ b/src/tasks/task-status.ts @@ -1,5 +1,6 @@ // Builds task status summaries and formatted status text for user-facing surfaces. import { sanitizeUserFacingText } from "../agents/embedded-agent-helpers/sanitize-user-facing-text.js"; +import { renderUserFacingText } from "../agents/embedded-agent-helpers/user-facing-text.js"; import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END, @@ -72,7 +73,7 @@ function stripInlineLeakedInternalContext(value: string): string { function sanitizeTaskStatusValue(value: unknown, errorContext: boolean): unknown { if (typeof value === "string") { - const sanitized = sanitizeUserFacingText(stripInlineLeakedInternalContext(value), { + const sanitized = renderUserFacingText(stripInlineLeakedInternalContext(value), { errorContext, }) .replace(/\s+/g, " ")