Skip to content

Commit 8ffa3a8

Browse files
committed
fix commitments extractor model selection
1 parent 3c48510 commit 8ffa3a8

3 files changed

Lines changed: 170 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212
### Fixes
1313

1414
- 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.
15+
- 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.
1516
- 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.
1617
- 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.
1718
- Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord.

src/commitments/runtime.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ import {
1313
import { loadCommitmentStore } from "./store.js";
1414
import type { CommitmentExtractionBatchResult, CommitmentExtractionItem } from "./types.js";
1515

16+
const runEmbeddedPiAgentMock = vi.hoisted(() => vi.fn());
17+
18+
vi.mock("../agents/pi-embedded.js", () => ({
19+
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
20+
}));
21+
1622
describe("commitment extraction runtime", () => {
1723
const tmpDirs: string[] = [];
1824
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
1925

2026
afterEach(async () => {
2127
resetCommitmentExtractionRuntimeForTests();
28+
runEmbeddedPiAgentMock.mockReset();
29+
vi.useRealTimers();
2230
vi.unstubAllEnvs();
2331
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
2432
tmpDirs.length = 0;
@@ -145,6 +153,113 @@ describe("commitment extraction runtime", () => {
145153
expect(store.commitments[0]).not.toHaveProperty("sourceAssistantText");
146154
});
147155

156+
it("uses the configured agent model for the hidden extractor run", async () => {
157+
const cfg = await createConfig();
158+
cfg.agents = {
159+
defaults: {
160+
model: {
161+
primary: "openai-codex/gpt-5.5",
162+
},
163+
},
164+
};
165+
runEmbeddedPiAgentMock.mockResolvedValue({
166+
payloads: [{ text: '{"candidates":[]}' }],
167+
});
168+
configureCommitmentExtractionRuntime({
169+
forceInTests: true,
170+
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
171+
clearTimer: () => undefined,
172+
});
173+
174+
expect(
175+
enqueueCommitmentExtraction({
176+
cfg,
177+
nowMs,
178+
agentId: "main",
179+
sessionKey: "agent:main:discord:channel-1",
180+
channel: "discord",
181+
userText: "I have an interview tomorrow.",
182+
assistantText: "Good luck.",
183+
}),
184+
).toBe(true);
185+
186+
await expect(drainCommitmentExtractionQueue()).resolves.toBe(1);
187+
expect(runEmbeddedPiAgentMock).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
provider: "openai-codex",
190+
model: "gpt-5.5",
191+
disableTools: true,
192+
}),
193+
);
194+
});
195+
196+
it("backs off hidden extraction after terminal model or auth failures", async () => {
197+
vi.useFakeTimers();
198+
vi.setSystemTime(nowMs);
199+
const cfg = await createConfig();
200+
const extractBatch = vi.fn(async () => {
201+
throw new Error(
202+
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth.',
203+
);
204+
});
205+
configureCommitmentExtractionRuntime({
206+
forceInTests: true,
207+
extractBatch,
208+
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
209+
clearTimer: () => undefined,
210+
});
211+
212+
expect(
213+
enqueueCommitmentExtraction({
214+
cfg,
215+
nowMs,
216+
agentId: "main",
217+
sessionKey: "agent:main:discord:channel-1",
218+
channel: "discord",
219+
userText: "I have an interview tomorrow.",
220+
assistantText: "Good luck.",
221+
}),
222+
).toBe(true);
223+
224+
await expect(drainCommitmentExtractionQueue()).rejects.toThrow("No API key found");
225+
expect(extractBatch).toHaveBeenCalledTimes(1);
226+
expect(
227+
enqueueCommitmentExtraction({
228+
cfg,
229+
nowMs: nowMs + 1,
230+
agentId: "main",
231+
sessionKey: "agent:main:discord:channel-1",
232+
channel: "discord",
233+
userText: "The interview is tomorrow.",
234+
assistantText: "I hope it goes well.",
235+
}),
236+
).toBe(false);
237+
expect(
238+
enqueueCommitmentExtraction({
239+
cfg,
240+
nowMs: nowMs + 1,
241+
agentId: "other",
242+
sessionKey: "agent:other:discord:channel-2",
243+
channel: "discord",
244+
userText: "The demo is tomorrow.",
245+
assistantText: "I hope it goes well.",
246+
}),
247+
).toBe(true);
248+
249+
vi.setSystemTime(nowMs + 16 * 60_000);
250+
expect(
251+
enqueueCommitmentExtraction({
252+
cfg,
253+
nowMs: nowMs + 16 * 60_000,
254+
agentId: "main",
255+
sessionKey: "agent:main:discord:channel-1",
256+
channel: "discord",
257+
userText: "The interview is tomorrow.",
258+
assistantText: "I hope it goes well.",
259+
}),
260+
).toBe(true);
261+
});
262+
148263
it("bounds hidden extraction queue growth before spending extractor tokens", async () => {
149264
const cfg = await createConfig();
150265
const extractBatch = vi.fn(

src/commitments/runtime.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from "node:crypto";
22
import path from "node:path";
33
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
4+
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
45
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../agents/pi-embedded.js";
56
import type { OpenClawConfig } from "../config/config.js";
67
import { resolveStateDir } from "../config/paths.js";
@@ -41,12 +42,14 @@ export type CommitmentExtractionRuntime = {
4142
};
4243

4344
const log = createSubsystemLogger("commitments");
45+
const TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS = 15 * 60_000;
4446

4547
let runtime: CommitmentExtractionRuntime = {};
4648
let queue: Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }> = [];
4749
let timer: TimerHandle | null = null;
4850
let draining = false;
4951
let queueOverflowWarned = false;
52+
let terminalFailureCooldownUntilByAgent = new Map<string, number>();
5053

5154
function shouldDisableBackgroundExtractionForTests(): boolean {
5255
if (runtime.forceInTests) {
@@ -82,6 +85,7 @@ export function resetCommitmentExtractionRuntimeForTests(): void {
8285
timer = null;
8386
draining = false;
8487
queueOverflowWarned = false;
88+
terminalFailureCooldownUntilByAgent = new Map();
8589
}
8690

8791
function buildItemId(params: CommitmentExtractionEnqueueInput, nowMs: number): string {
@@ -95,14 +99,19 @@ function isUsefulText(value: string | undefined): boolean {
9599

96100
export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueInput): boolean {
97101
const resolved = resolveCommitmentsConfig(input.cfg);
102+
const nowMs = input.nowMs ?? Date.now();
103+
const agentId = normalizeOptionalString(input.agentId) ?? "";
104+
const sessionKey = normalizeOptionalString(input.sessionKey) ?? "";
105+
const channel = normalizeOptionalString(input.channel) ?? "";
98106
if (
99107
!resolved.enabled ||
100108
shouldDisableBackgroundExtractionForTests() ||
109+
(agentId ? nowMs < (terminalFailureCooldownUntilByAgent.get(agentId) ?? 0) : false) ||
101110
!isUsefulText(input.userText) ||
102111
!isUsefulText(input.assistantText) ||
103-
!input.agentId.trim() ||
104-
!input.sessionKey.trim() ||
105-
!input.channel.trim()
112+
!agentId ||
113+
!sessionKey ||
114+
!channel
106115
) {
107116
return false;
108117
}
@@ -116,14 +125,13 @@ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueIn
116125
}
117126
return false;
118127
}
119-
const nowMs = input.nowMs ?? Date.now();
120128
queue.push({
121129
itemId: buildItemId(input, nowMs),
122130
nowMs,
123131
timezone: resolveCommitmentTimezone(input.cfg),
124-
agentId: input.agentId.trim(),
125-
sessionKey: input.sessionKey.trim(),
126-
channel: input.channel.trim(),
132+
agentId,
133+
sessionKey,
134+
channel,
127135
...(input.accountId?.trim() ? { accountId: input.accountId.trim() } : {}),
128136
...(input.to?.trim() ? { to: input.to.trim() } : {}),
129137
...(input.threadId?.trim() ? { threadId: input.threadId.trim() } : {}),
@@ -145,6 +153,33 @@ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueIn
145153
return true;
146154
}
147155

156+
function isTerminalExtractionError(error: unknown): boolean {
157+
const message = error instanceof Error ? error.message : String(error);
158+
return (
159+
/\bNo API key found\b/i.test(message) ||
160+
/\bUnknown model\b/i.test(message) ||
161+
/\bAuth profile credentials are missing or expired\b/i.test(message) ||
162+
/\bOAuth token refresh failed\b/i.test(message) ||
163+
/\bmissing credential\b/i.test(message) ||
164+
/\bmissing credentials\b/i.test(message) ||
165+
/\bmissing_api_key\b/i.test(message) ||
166+
/\binvalid_grant\b/i.test(message)
167+
);
168+
}
169+
170+
function openTerminalFailureCooldown(agentId: string, error: unknown): void {
171+
terminalFailureCooldownUntilByAgent.set(
172+
agentId,
173+
Date.now() + TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS,
174+
);
175+
queue = queue.filter((item) => item.agentId !== agentId);
176+
log.warn("commitment extraction disabled temporarily after terminal model/auth failure", {
177+
agentId,
178+
cooldownMs: TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS,
179+
error: String(error),
180+
});
181+
}
182+
148183
function resolveExtractionSessionFile(agentId: string, runId: string): string {
149184
return path.join(
150185
resolveStateDir(),
@@ -176,6 +211,7 @@ async function defaultExtractBatch(params: {
176211
}
177212
const resolved = resolveCommitmentsConfig(cfg);
178213
const runId = `commitments-${randomUUID()}`;
214+
const modelRef = resolveDefaultModelForAgent({ cfg, agentId: first.agentId });
179215
const result = await runEmbeddedPiAgent({
180216
sessionId: runId,
181217
sessionKey: `agent:${first.agentId}:commitments:${runId}`,
@@ -184,6 +220,8 @@ async function defaultExtractBatch(params: {
184220
sessionFile: resolveExtractionSessionFile(first.agentId, runId),
185221
workspaceDir: resolveAgentWorkspaceDir(cfg, first.agentId),
186222
config: cfg,
223+
provider: modelRef.provider,
224+
model: modelRef.model,
187225
prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }),
188226
disableTools: true,
189227
thinkLevel: "off",
@@ -225,7 +263,15 @@ export async function drainCommitmentExtractionQueue(): Promise<number> {
225263
const batch = queue.splice(0, resolved.extraction.batchMaxItems);
226264
const items = await hydrateBatch(batch);
227265
const extractor = runtime.extractBatch ?? defaultExtractBatch;
228-
const result = await extractor({ cfg: firstCfg, items });
266+
let result: CommitmentExtractionBatchResult;
267+
try {
268+
result = await extractor({ cfg: firstCfg, items });
269+
} catch (error) {
270+
if (isTerminalExtractionError(error)) {
271+
openTerminalFailureCooldown(items[0]?.agentId ?? "", error);
272+
}
273+
throw error;
274+
}
229275
await persistCommitmentExtractionResult({
230276
cfg: firstCfg,
231277
items,

0 commit comments

Comments
 (0)