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