mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 06:30:44 +00:00
fix commitments extractor model selection (#75347)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user