diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fd873ccfe..393ed45ed86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- 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. - 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. diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index d0396039632..207e721ac81 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -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(() => {}) }; + + 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 () => { const sm = guardSessionManager(SessionManager.inMemory()); vi.useFakeTimers(); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 1742c554033..2bfc9e0a5ce 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -817,6 +817,7 @@ export async function compactEmbeddedPiSessionDirect( await flushPendingToolResultsAfterIdle({ agent: session?.agent, sessionManager, + clearPendingOnTimeout: true, }); session.dispose(); } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e19dd3cedb2..56223333ccf 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1338,6 +1338,7 @@ export async function runEmbeddedAttempt( await flushPendingToolResultsAfterIdle({ agent: activeSession?.agent, sessionManager, + clearPendingOnTimeout: true, }); activeSession.dispose(); throw err; @@ -1904,6 +1905,7 @@ export async function runEmbeddedAttempt( await flushPendingToolResultsAfterIdle({ agent: session?.agent, sessionManager, + clearPendingOnTimeout: true, }); session?.dispose(); releaseWsSession(params.sessionId); diff --git a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts index c3cefd7d17e..71b661aadb7 100644 --- a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts +++ b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts @@ -4,6 +4,7 @@ type IdleAwareAgent = { type ToolResultFlushManager = { flushPendingToolResults?: (() => void) | undefined; + clearPendingToolResults?: (() => void) | undefined; }; 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( agent: IdleAwareAgent | null | undefined, timeoutMs: number, -): Promise { +): Promise { const waitForIdle = agent?.waitForIdle; if (typeof waitForIdle !== "function") { - return; + return false; } + const idleResolved = Symbol("idle"); + const idleTimedOut = Symbol("timeout"); let timeoutHandle: ReturnType | undefined; try { - await Promise.race([ - waitForIdle.call(agent), - new Promise((resolve) => { - timeoutHandle = setTimeout(resolve, timeoutMs); + const outcome = await Promise.race([ + waitForIdle.call(agent).then(() => idleResolved), + new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve(idleTimedOut), timeoutMs); timeoutHandle.unref?.(); }), ]); + return outcome === idleTimedOut; } catch { // Best-effort during cleanup. + return false; } finally { if (timeoutHandle) { clearTimeout(timeoutHandle); @@ -39,7 +44,15 @@ export async function flushPendingToolResultsAfterIdle(opts: { agent: IdleAwareAgent | null | undefined; sessionManager: ToolResultFlushManager | null | undefined; timeoutMs?: number; + clearPendingOnTimeout?: boolean; }): Promise { - 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?.(); } diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index 8570bdd1687..c9ca8899712 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -9,6 +9,8 @@ import { installSessionToolResultGuard } from "./session-tool-result-guard.js"; export type GuardedSessionManager = SessionManager & { /** Flush any synthetic tool results for pending tool calls. Idempotent. */ flushPendingToolResults?: () => void; + /** Clear pending tool calls without persisting synthetic tool results. Idempotent. */ + clearPendingToolResults?: () => void; }; /** @@ -69,5 +71,6 @@ export function guardSessionManager( beforeMessageWriteHook: beforeMessageWrite, }); (sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults; + (sessionManager as GuardedSessionManager).clearPendingToolResults = guard.clearPendingToolResults; return sessionManager as GuardedSessionManager; } diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index e7366785cea..36e06d52dec 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -111,6 +111,17 @@ describe("installSessionToolResultGuard", () => { 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", () => { const sm = SessionManager.inMemory(); const guard = installSessionToolResultGuard(sm, { diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 4ec5fe6c8cb..cb5d465754e 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -104,6 +104,7 @@ export function installSessionToolResultGuard( }, ): { flushPendingToolResults: () => void; + clearPendingToolResults: () => void; getPendingIds: () => string[]; } { const originalAppend = sessionManager.appendMessage.bind(sessionManager); @@ -164,6 +165,10 @@ export function installSessionToolResultGuard( pendingState.clear(); }; + const clearPendingToolResults = () => { + pendingState.clear(); + }; + const guardedAppend = (message: AgentMessage) => { let nextMessage = message; const role = (message as { role?: unknown }).role; @@ -255,6 +260,7 @@ export function installSessionToolResultGuard( return { flushPendingToolResults, + clearPendingToolResults, getPendingIds: pendingState.getPendingIds, }; }