fix(agents): avoid synthetic tool-result writes on idle-timeout cleanup

This commit is contained in:
Vignesh Natarajan
2026-03-05 19:29:18 -08:00
parent 81b93b9ce0
commit 4daaea1190
8 changed files with 71 additions and 7 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub. - Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy. - Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan. - Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
- Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic `missing tool result` entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
- Cron/OpenAI Codex OAuth refresh hardening: when `openai-codex` token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal. - Cron/OpenAI Codex OAuth refresh hardening: when `openai-codex` token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.
- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao.

View File

@@ -97,6 +97,33 @@ describe("flushPendingToolResultsAfterIdle", () => {
); );
}); });
it("clears pending without synthetic flush when timeout cleanup is requested", async () => {
const sm = guardSessionManager(SessionManager.inMemory());
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
vi.useFakeTimers();
const agent = { waitForIdle: () => new Promise<void>(() => {}) };
appendMessage(assistantToolCall("call_orphan_2"));
const flushPromise = flushPendingToolResultsAfterIdle({
agent,
sessionManager: sm,
timeoutMs: 30,
clearPendingOnTimeout: true,
});
await vi.advanceTimersByTimeAsync(30);
await flushPromise;
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]);
appendMessage({
role: "user",
content: "still there?",
timestamp: Date.now(),
} as AgentMessage);
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "user"]);
});
it("clears timeout handle when waitForIdle resolves first", async () => { it("clears timeout handle when waitForIdle resolves first", async () => {
const sm = guardSessionManager(SessionManager.inMemory()); const sm = guardSessionManager(SessionManager.inMemory());
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -817,6 +817,7 @@ export async function compactEmbeddedPiSessionDirect(
await flushPendingToolResultsAfterIdle({ await flushPendingToolResultsAfterIdle({
agent: session?.agent, agent: session?.agent,
sessionManager, sessionManager,
clearPendingOnTimeout: true,
}); });
session.dispose(); session.dispose();
} }

View File

@@ -1338,6 +1338,7 @@ export async function runEmbeddedAttempt(
await flushPendingToolResultsAfterIdle({ await flushPendingToolResultsAfterIdle({
agent: activeSession?.agent, agent: activeSession?.agent,
sessionManager, sessionManager,
clearPendingOnTimeout: true,
}); });
activeSession.dispose(); activeSession.dispose();
throw err; throw err;
@@ -1904,6 +1905,7 @@ export async function runEmbeddedAttempt(
await flushPendingToolResultsAfterIdle({ await flushPendingToolResultsAfterIdle({
agent: session?.agent, agent: session?.agent,
sessionManager, sessionManager,
clearPendingOnTimeout: true,
}); });
session?.dispose(); session?.dispose();
releaseWsSession(params.sessionId); releaseWsSession(params.sessionId);

View File

@@ -4,6 +4,7 @@ type IdleAwareAgent = {
type ToolResultFlushManager = { type ToolResultFlushManager = {
flushPendingToolResults?: (() => void) | undefined; flushPendingToolResults?: (() => void) | undefined;
clearPendingToolResults?: (() => void) | undefined;
}; };
export const DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 30_000; export const DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 30_000;
@@ -11,23 +12,27 @@ export const DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 30_000;
async function waitForAgentIdleBestEffort( async function waitForAgentIdleBestEffort(
agent: IdleAwareAgent | null | undefined, agent: IdleAwareAgent | null | undefined,
timeoutMs: number, timeoutMs: number,
): Promise<void> { ): Promise<boolean> {
const waitForIdle = agent?.waitForIdle; const waitForIdle = agent?.waitForIdle;
if (typeof waitForIdle !== "function") { if (typeof waitForIdle !== "function") {
return; return false;
} }
const idleResolved = Symbol("idle");
const idleTimedOut = Symbol("timeout");
let timeoutHandle: ReturnType<typeof setTimeout> | undefined; let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try { try {
await Promise.race([ const outcome = await Promise.race([
waitForIdle.call(agent), waitForIdle.call(agent).then(() => idleResolved),
new Promise<void>((resolve) => { new Promise<symbol>((resolve) => {
timeoutHandle = setTimeout(resolve, timeoutMs); timeoutHandle = setTimeout(() => resolve(idleTimedOut), timeoutMs);
timeoutHandle.unref?.(); timeoutHandle.unref?.();
}), }),
]); ]);
return outcome === idleTimedOut;
} catch { } catch {
// Best-effort during cleanup. // Best-effort during cleanup.
return false;
} finally { } finally {
if (timeoutHandle) { if (timeoutHandle) {
clearTimeout(timeoutHandle); clearTimeout(timeoutHandle);
@@ -39,7 +44,15 @@ export async function flushPendingToolResultsAfterIdle(opts: {
agent: IdleAwareAgent | null | undefined; agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined; sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number; timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}): Promise<void> { }): Promise<void> {
await waitForAgentIdleBestEffort(opts.agent, opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS); const timedOut = await waitForAgentIdleBestEffort(
opts.agent,
opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS,
);
if (timedOut && opts.clearPendingOnTimeout && opts.sessionManager?.clearPendingToolResults) {
opts.sessionManager.clearPendingToolResults();
return;
}
opts.sessionManager?.flushPendingToolResults?.(); opts.sessionManager?.flushPendingToolResults?.();
} }

View File

@@ -9,6 +9,8 @@ import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
export type GuardedSessionManager = SessionManager & { export type GuardedSessionManager = SessionManager & {
/** Flush any synthetic tool results for pending tool calls. Idempotent. */ /** Flush any synthetic tool results for pending tool calls. Idempotent. */
flushPendingToolResults?: () => void; flushPendingToolResults?: () => void;
/** Clear pending tool calls without persisting synthetic tool results. Idempotent. */
clearPendingToolResults?: () => void;
}; };
/** /**
@@ -69,5 +71,6 @@ export function guardSessionManager(
beforeMessageWriteHook: beforeMessageWrite, beforeMessageWriteHook: beforeMessageWrite,
}); });
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults; (sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
(sessionManager as GuardedSessionManager).clearPendingToolResults = guard.clearPendingToolResults;
return sessionManager as GuardedSessionManager; return sessionManager as GuardedSessionManager;
} }

View File

@@ -111,6 +111,17 @@ describe("installSessionToolResultGuard", () => {
expectPersistedRoles(sm, ["assistant", "toolResult"]); expectPersistedRoles(sm, ["assistant", "toolResult"]);
}); });
it("clears pending tool calls without inserting synthetic tool results", () => {
const sm = SessionManager.inMemory();
const guard = installSessionToolResultGuard(sm);
sm.appendMessage(toolCallMessage);
guard.clearPendingToolResults();
expectPersistedRoles(sm, ["assistant"]);
expect(guard.getPendingIds()).toEqual([]);
});
it("clears pending on user interruption when synthetic tool results are disabled", () => { it("clears pending on user interruption when synthetic tool results are disabled", () => {
const sm = SessionManager.inMemory(); const sm = SessionManager.inMemory();
const guard = installSessionToolResultGuard(sm, { const guard = installSessionToolResultGuard(sm, {

View File

@@ -104,6 +104,7 @@ export function installSessionToolResultGuard(
}, },
): { ): {
flushPendingToolResults: () => void; flushPendingToolResults: () => void;
clearPendingToolResults: () => void;
getPendingIds: () => string[]; getPendingIds: () => string[];
} { } {
const originalAppend = sessionManager.appendMessage.bind(sessionManager); const originalAppend = sessionManager.appendMessage.bind(sessionManager);
@@ -164,6 +165,10 @@ export function installSessionToolResultGuard(
pendingState.clear(); pendingState.clear();
}; };
const clearPendingToolResults = () => {
pendingState.clear();
};
const guardedAppend = (message: AgentMessage) => { const guardedAppend = (message: AgentMessage) => {
let nextMessage = message; let nextMessage = message;
const role = (message as { role?: unknown }).role; const role = (message as { role?: unknown }).role;
@@ -255,6 +260,7 @@ export function installSessionToolResultGuard(
return { return {
flushPendingToolResults, flushPendingToolResults,
clearPendingToolResults,
getPendingIds: pendingState.getPendingIds, getPendingIds: pendingState.getPendingIds,
}; };
} }