fix commitments extractor model selection (#75347)

This commit is contained in:
Vignesh
2026-04-30 18:57:21 -07:00
committed by GitHub
parent 4b98f09529
commit 38da2ac6f8
3 changed files with 170 additions and 8 deletions

View File

@@ -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<typeof setTimeout>,
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<typeof setTimeout>,
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(

View File

@@ -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<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }> = [];
let timer: TimerHandle | null = null;
let draining = false;
let queueOverflowWarned = false;
let terminalFailureCooldownUntilByAgent = new Map<string, number>();
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<number> {
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,