Skip to content

Commit 1911854

Browse files
committed
fix(session): bound local tool timeout suspension
1 parent 07e59ce commit 1911854

4 files changed

Lines changed: 49 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ In headless mode, Aictrl automatically rejects all interactive permission reques
5353
### CI/CD Integration
5454
Set `AICTRL_HEADLESS=true` in your environment to force headless behavior even in pseudo-TTYs.
5555

56+
### Model Stream Idle Timeout
57+
5658
Model streams have a five-minute idle timeout by default. Every stream event resets
57-
the timer, so long-running responses that continue making progress are unaffected.
59+
the timer, so long-running responses that continue making progress are unaffected. Local
60+
tool execution uses a ceiling twelve times the configured model timeout (one hour by default).
5861
Set `AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS` to a decimal integer of milliseconds through
5962
2147483647 to override the timeout, or `0` to disable it. Missing, empty, negative,
6063
fractional, non-decimal, non-numeric, or unsupported values use the 300000 ms default.

‎packages/cli/src/session/idle.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { MessageV2 } from "./message-v2"
22

33
export namespace StreamIdle {
4-
function error(ms: number) {
4+
function error(ms: number, message = `Model stream produced no events for ${ms}ms`) {
55
return new MessageV2.StreamIdleTimeoutError({
6-
message: `Model stream produced no events for ${ms}ms`,
6+
message,
77
timeout: ms,
88
})
99
}
@@ -21,6 +21,7 @@ export namespace StreamIdle {
2121
ms: number,
2222
abort: () => void,
2323
updateSuspended: (value: T) => boolean = () => false,
24+
suspendedTimeout = ms,
2425
) {
2526
if (ms === 0) {
2627
yield* stream
@@ -31,18 +32,19 @@ export namespace StreamIdle {
3132
let suspended = false
3233
try {
3334
while (true) {
34-
if (suspended) {
35-
const next = await iterator.next()
36-
if (next.done) return
37-
suspended = updateSuspended(next.value)
38-
yield next.value
39-
continue
40-
}
4135
const timer = Promise.withResolvers<never>()
36+
const timeout = suspended ? suspendedTimeout : ms
4237
const id = setTimeout(() => {
43-
timer.reject(error(ms))
38+
timer.reject(
39+
error(
40+
timeout,
41+
suspended
42+
? `Local tool execution produced no result for ${timeout}ms`
43+
: `Model stream produced no events for ${timeout}ms`,
44+
),
45+
)
4446
abort()
45-
}, ms)
47+
}, timeout)
4648
const next = await Promise.race([iterator.next(), timer.promise]).finally(() => clearTimeout(id))
4749
if (next.done) return
4850
suspended = updateSuspended(next.value)

‎packages/cli/src/session/processor.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { NamedError } from "@aictrl/util/error"
2121

2222
export namespace SessionProcessor {
2323
const DOOM_LOOP_THRESHOLD = 3
24+
const LOCAL_TOOL_TIMEOUT_MULTIPLIER = 12
2425
const log = Log.create({ service: "session.processor" })
2526

2627
export type Info = Awaited<ReturnType<typeof create>>
@@ -68,7 +69,7 @@ export namespace SessionProcessor {
6869
if (
6970
value.type === "tool-call" &&
7071
!value.providerExecuted &&
71-
typeof streamInput.tools[value.toolName]?.execute === "function"
72+
typeof streamInput.tools?.[value.toolName]?.execute === "function"
7273
) {
7374
runningTools.add(value.toolCallId)
7475
}
@@ -77,6 +78,10 @@ export namespace SessionProcessor {
7778
}
7879
return runningTools.size > 0
7980
},
81+
Math.min(
82+
Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS * LOCAL_TOOL_TIMEOUT_MULTIPLIER,
83+
Flag.AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MAX,
84+
),
8085
)) {
8186
input.abort.throwIfAborted()
8287
switch (value.type) {

‎packages/cli/test/session/idle.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,32 @@ describe("model stream idle timeout", () => {
105105
expect(values).toEqual([1, 2, 3])
106106
})
107107

108+
test("bounds a suspended local tool wait", async () => {
109+
async function* stream() {
110+
yield "tool-call"
111+
await new Promise(() => {})
112+
}
113+
let aborted = false
114+
const result = StreamIdle.timeout(
115+
stream(),
116+
10,
117+
() => {
118+
aborted = true
119+
},
120+
(value) => value === "tool-call",
121+
30,
122+
)
123+
124+
expect(await result.next()).toEqual({ done: false, value: "tool-call" })
125+
const error = await result.next().catch((value) => value)
126+
expect(aborted).toBe(true)
127+
expect(MessageV2.StreamIdleTimeoutError.isInstance(error)).toBe(true)
128+
expect(error.data).toEqual({
129+
message: "Local tool execution produced no result for 30ms",
130+
timeout: 30,
131+
})
132+
})
133+
108134
test("zero disables the timeout", async () => {
109135
async function* stream() {
110136
await Bun.sleep(15)

0 commit comments

Comments
 (0)