fix(agents): avoid synthetic tool-result writes on idle-timeout cleanup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
const sm = guardSessionManager(SessionManager.inMemory());
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -817,6 +817,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
await flushPendingToolResultsAfterIdle({
|
||||
agent: session?.agent,
|
||||
sessionManager,
|
||||
clearPendingOnTimeout: true,
|
||||
});
|
||||
session.dispose();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<boolean> {
|
||||
const waitForIdle = agent?.waitForIdle;
|
||||
if (typeof waitForIdle !== "function") {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const idleResolved = Symbol("idle");
|
||||
const idleTimedOut = Symbol("timeout");
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
waitForIdle.call(agent),
|
||||
new Promise<void>((resolve) => {
|
||||
timeoutHandle = setTimeout(resolve, timeoutMs);
|
||||
const outcome = await Promise.race([
|
||||
waitForIdle.call(agent).then(() => idleResolved),
|
||||
new Promise<symbol>((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<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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user