From 38da2ac6f8ee91818aee69aeeea60fd5fe49f08c Mon Sep 17 00:00:00 2001 From: Vignesh Date: Thu, 30 Apr 2026 18:57:21 -0700 Subject: [PATCH] fix commitments extractor model selection (#75347) --- CHANGELOG.md | 1 + src/commitments/runtime.test.ts | 115 ++++++++++++++++++++++++++++++++ src/commitments/runtime.ts | 62 ++++++++++++++--- 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cefaf70fb..064c01de23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. +- Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337. - Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan. - Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index a969054c849..4d01d4000fb 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -13,12 +13,20 @@ import { import { loadCommitmentStore } from "./store.js"; import type { CommitmentExtractionBatchResult, CommitmentExtractionItem } from "./types.js"; +const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: runEmbeddedPiAgentMock, +})); + describe("commitment extraction runtime", () => { const tmpDirs: string[] = []; const nowMs = Date.parse("2026-04-29T16:00:00.000Z"); afterEach(async () => { resetCommitmentExtractionRuntimeForTests(); + runEmbeddedPiAgentMock.mockReset(); + vi.useRealTimers(); vi.unstubAllEnvs(); await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); tmpDirs.length = 0; @@ -145,6 +153,113 @@ describe("commitment extraction runtime", () => { expect(store.commitments[0]).not.toHaveProperty("sourceAssistantText"); }); + it("uses the configured agent model for the hidden extractor run", async () => { + const cfg = await createConfig(); + cfg.agents = { + defaults: { + model: { + primary: "openai-codex/gpt-5.5", + }, + }, + }; + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: '{"candidates":[]}' }], + }); + configureCommitmentExtractionRuntime({ + forceInTests: true, + setTimer: () => ({ unref() {} }) as ReturnType, + clearTimer: () => undefined, + }); + + expect( + enqueueCommitmentExtraction({ + cfg, + nowMs, + agentId: "main", + sessionKey: "agent:main:discord:channel-1", + channel: "discord", + userText: "I have an interview tomorrow.", + assistantText: "Good luck.", + }), + ).toBe(true); + + await expect(drainCommitmentExtractionQueue()).resolves.toBe(1); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + model: "gpt-5.5", + disableTools: true, + }), + ); + }); + + it("backs off hidden extraction after terminal model or auth failures", async () => { + vi.useFakeTimers(); + vi.setSystemTime(nowMs); + const cfg = await createConfig(); + const extractBatch = vi.fn(async () => { + throw new Error( + 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth.', + ); + }); + configureCommitmentExtractionRuntime({ + forceInTests: true, + extractBatch, + setTimer: () => ({ unref() {} }) as ReturnType, + clearTimer: () => undefined, + }); + + expect( + enqueueCommitmentExtraction({ + cfg, + nowMs, + agentId: "main", + sessionKey: "agent:main:discord:channel-1", + channel: "discord", + userText: "I have an interview tomorrow.", + assistantText: "Good luck.", + }), + ).toBe(true); + + await expect(drainCommitmentExtractionQueue()).rejects.toThrow("No API key found"); + expect(extractBatch).toHaveBeenCalledTimes(1); + expect( + enqueueCommitmentExtraction({ + cfg, + nowMs: nowMs + 1, + agentId: "main", + sessionKey: "agent:main:discord:channel-1", + channel: "discord", + userText: "The interview is tomorrow.", + assistantText: "I hope it goes well.", + }), + ).toBe(false); + expect( + enqueueCommitmentExtraction({ + cfg, + nowMs: nowMs + 1, + agentId: "other", + sessionKey: "agent:other:discord:channel-2", + channel: "discord", + userText: "The demo is tomorrow.", + assistantText: "I hope it goes well.", + }), + ).toBe(true); + + vi.setSystemTime(nowMs + 16 * 60_000); + expect( + enqueueCommitmentExtraction({ + cfg, + nowMs: nowMs + 16 * 60_000, + agentId: "main", + sessionKey: "agent:main:discord:channel-1", + channel: "discord", + userText: "The interview is tomorrow.", + assistantText: "I hope it goes well.", + }), + ).toBe(true); + }); + it("bounds hidden extraction queue growth before spending extractor tokens", async () => { const cfg = await createConfig(); const extractBatch = vi.fn( diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts index 8f2517b6c20..2349c32b224 100644 --- a/src/commitments/runtime.ts +++ b/src/commitments/runtime.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; @@ -41,12 +42,14 @@ export type CommitmentExtractionRuntime = { }; const log = createSubsystemLogger("commitments"); +const TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS = 15 * 60_000; let runtime: CommitmentExtractionRuntime = {}; let queue: Array & { cfg?: OpenClawConfig }> = []; let timer: TimerHandle | null = null; let draining = false; let queueOverflowWarned = false; +let terminalFailureCooldownUntilByAgent = new Map(); function shouldDisableBackgroundExtractionForTests(): boolean { if (runtime.forceInTests) { @@ -82,6 +85,7 @@ export function resetCommitmentExtractionRuntimeForTests(): void { timer = null; draining = false; queueOverflowWarned = false; + terminalFailureCooldownUntilByAgent = new Map(); } function buildItemId(params: CommitmentExtractionEnqueueInput, nowMs: number): string { @@ -95,14 +99,19 @@ function isUsefulText(value: string | undefined): boolean { export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueInput): boolean { const resolved = resolveCommitmentsConfig(input.cfg); + const nowMs = input.nowMs ?? Date.now(); + const agentId = normalizeOptionalString(input.agentId) ?? ""; + const sessionKey = normalizeOptionalString(input.sessionKey) ?? ""; + const channel = normalizeOptionalString(input.channel) ?? ""; if ( !resolved.enabled || shouldDisableBackgroundExtractionForTests() || + (agentId ? nowMs < (terminalFailureCooldownUntilByAgent.get(agentId) ?? 0) : false) || !isUsefulText(input.userText) || !isUsefulText(input.assistantText) || - !input.agentId.trim() || - !input.sessionKey.trim() || - !input.channel.trim() + !agentId || + !sessionKey || + !channel ) { return false; } @@ -116,14 +125,13 @@ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueIn } return false; } - const nowMs = input.nowMs ?? Date.now(); queue.push({ itemId: buildItemId(input, nowMs), nowMs, timezone: resolveCommitmentTimezone(input.cfg), - agentId: input.agentId.trim(), - sessionKey: input.sessionKey.trim(), - channel: input.channel.trim(), + agentId, + sessionKey, + channel, ...(input.accountId?.trim() ? { accountId: input.accountId.trim() } : {}), ...(input.to?.trim() ? { to: input.to.trim() } : {}), ...(input.threadId?.trim() ? { threadId: input.threadId.trim() } : {}), @@ -145,6 +153,33 @@ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueIn return true; } +function isTerminalExtractionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + /\bNo API key found\b/i.test(message) || + /\bUnknown model\b/i.test(message) || + /\bAuth profile credentials are missing or expired\b/i.test(message) || + /\bOAuth token refresh failed\b/i.test(message) || + /\bmissing credential\b/i.test(message) || + /\bmissing credentials\b/i.test(message) || + /\bmissing_api_key\b/i.test(message) || + /\binvalid_grant\b/i.test(message) + ); +} + +function openTerminalFailureCooldown(agentId: string, error: unknown): void { + terminalFailureCooldownUntilByAgent.set( + agentId, + Date.now() + TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS, + ); + queue = queue.filter((item) => item.agentId !== agentId); + log.warn("commitment extraction disabled temporarily after terminal model/auth failure", { + agentId, + cooldownMs: TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS, + error: String(error), + }); +} + function resolveExtractionSessionFile(agentId: string, runId: string): string { return path.join( resolveStateDir(), @@ -176,6 +211,7 @@ async function defaultExtractBatch(params: { } const resolved = resolveCommitmentsConfig(cfg); const runId = `commitments-${randomUUID()}`; + const modelRef = resolveDefaultModelForAgent({ cfg, agentId: first.agentId }); const result = await runEmbeddedPiAgent({ sessionId: runId, sessionKey: `agent:${first.agentId}:commitments:${runId}`, @@ -184,6 +220,8 @@ async function defaultExtractBatch(params: { sessionFile: resolveExtractionSessionFile(first.agentId, runId), workspaceDir: resolveAgentWorkspaceDir(cfg, first.agentId), config: cfg, + provider: modelRef.provider, + model: modelRef.model, prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }), disableTools: true, thinkLevel: "off", @@ -225,7 +263,15 @@ export async function drainCommitmentExtractionQueue(): Promise { const batch = queue.splice(0, resolved.extraction.batchMaxItems); const items = await hydrateBatch(batch); const extractor = runtime.extractBatch ?? defaultExtractBatch; - const result = await extractor({ cfg: firstCfg, items }); + let result: CommitmentExtractionBatchResult; + try { + result = await extractor({ cfg: firstCfg, items }); + } catch (error) { + if (isTerminalExtractionError(error)) { + openTerminalFailureCooldown(items[0]?.agentId ?? "", error); + } + throw error; + } await persistCommitmentExtractionResult({ cfg: firstCfg, items,