mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 05:04:33 +00:00
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.
|
- 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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user