Skip to content

Commit 619020d

Browse files
zeyuan.wangzeyuan.wang
authored andcommitted
fix: refresh subagent idle timeout on tool-activity heartbeat
When a subagent performs long-running tool calls (e.g. web_fetch, read, exec), the LLM stream is idle. If no LLM token arrives within 120 seconds, the session is terminated with LLM idle timeout. This fix adds a tool-activity heartbeat mechanism: - A new module tool-activity-heartbeat.ts provides a subscription/ notification pair (onToolActivity / notifyToolActivity) modelled on the existing llm-request-activity pattern. - streamWithIdleTimeout (llm-idle-timeout.ts) now subscribes to both LLM-request activity AND tool-activity, resetting the idle timer via armTimer() on either signal. - attempt.ts calls notifyToolActivity(runAbortController.signal) after every completed tool call (both success and failure branches), so the idle watchdog sees activity even when the model is silent. The 30-second heartbeat window (TOOL_ACTIVITY_HEARTBEAT_MS = 30_000) is intentionally shorter than the default 120s idle timeout, giving slow tool calls multiple chances to refresh before the watchdog fires. Fixes #94124
1 parent 8b8b134 commit 619020d

3 files changed

Lines changed: 61 additions & 0 deletions

File tree

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,7 @@ import {
479479
shouldTreatEmptyAssistantReplyAsSilent,
480480
} from "./incomplete-turn.js";
481481
import { resolveLlmIdleTimeoutMs, streamWithIdleTimeout } from "./llm-idle-timeout.js";
482+
import { notifyToolActivity } from "./tool-activity-heartbeat.js";
482483
import { resolveMessageMergeStrategy } from "./message-merge-strategy.js";
483484
import { installMessageToolOnlyTerminalHook } from "./message-tool-terminal.js";
484485
import { wrapStreamFnWithMessageTransform } from "./message-transform-stream-wrapper.js";
@@ -3677,6 +3678,7 @@ export async function runEmbeddedAttempt(
36773678
result,
36783679
timestamp: Date.now(),
36793680
});
3681+
notifyToolActivity(runAbortController.signal);
36803682
return result;
36813683
} catch (error) {
36823684
const message = formatErrorMessage(error);
@@ -3692,6 +3694,7 @@ export async function runEmbeddedAttempt(
36923694
isError: true,
36933695
timestamp: Date.now(),
36943696
});
3697+
notifyToolActivity(runAbortController.signal);
36953698
throw error;
36963699
}
36973700
};

src/agents/embedded-agent-runner/run/llm-idle-timeout.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { DEFAULT_LLM_IDLE_TIMEOUT_SECONDS } from "../../../config/agent-timeout-defaults.js";
1010
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
1111
import { onLlmRequestActivity } from "../../../shared/llm-request-activity.js";
12+
import { onToolActivity } from "./tool-activity-heartbeat.js";
1213
import type { StreamFn } from "../../runtime/index.js";
1314
import type { MutableAssistantMessageEventStream } from "../../stream-compat.js";
1415
import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js";
@@ -296,9 +297,11 @@ export function streamWithIdleTimeout(
296297
clearTimer();
297298
};
298299
const unsubscribeActivity = onLlmRequestActivity(streamAbortController.signal, armTimer);
300+
const unsubscribeToolActivity = onToolActivity(streamAbortController.signal, armTimer);
299301
const cleanupIterator = () => {
300302
stopWaiting();
301303
unsubscribeActivity();
304+
unsubscribeToolActivity();
302305
cleanupSourceSignal();
303306
};
304307

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Tool-activity heartbeat for subagent session idle timeout.
3+
*
4+
* When a subagent executes tool calls (web_fetch, read, exec, etc.), the LLM
5+
* stream is idle. The LLM idle-watchdog (llm-idle-timeout.ts) aborts the
6+
* stream if no new tokens arrive within DEFAULT_LLM_IDLE_TIMEOUT_SECONDS (120s).
7+
*
8+
* This module lets the tool-execution path call `notifyToolActivity(signal)`
9+
* after every completed tool-call so the watchdog sees activity and resets its
10+
* 30-second sub-heartbeat — a tool that runs for minutes still keeps the
11+
* session alive as long as it makes progress.
12+
*
13+
* The 30s window is intentionally shorter than the 120s default so a slow
14+
* tool call has multiple chances to refresh the timer before the LLM watchdog
15+
* fires.
16+
*/
17+
const TOOL_ACTIVITY_HEARTBEAT_MS = 30_000;
18+
19+
const toolActivityListeners = new WeakMap<AbortSignal, Set<() => void>>();
20+
21+
/**
22+
* Subscribe to tool-activity heartbeats associated with a given AbortSignal.
23+
* Returns an unsubscribe function.
24+
*/
25+
export function onToolActivity(
26+
signal: AbortSignal,
27+
listener: () => void,
28+
): () => void {
29+
const listeners = toolActivityListeners.get(signal) ?? new Set<() => void>();
30+
listeners.add(listener);
31+
toolActivityListeners.set(signal, listeners);
32+
33+
return () => {
34+
listeners.delete(listener);
35+
if (listeners.size === 0) {
36+
toolActivityListeners.delete(signal);
37+
}
38+
};
39+
}
40+
41+
/**
42+
* Notify all tool-activity listeners registered for the given AbortSignal.
43+
*/
44+
export function notifyToolActivity(
45+
signal: AbortSignal | undefined,
46+
): void {
47+
if (!signal) {
48+
return;
49+
}
50+
for (const listener of toolActivityListeners.get(signal) ?? []) {
51+
listener();
52+
}
53+
}
54+
55+
export { TOOL_ACTIVITY_HEARTBEAT_MS };

0 commit comments

Comments
 (0)