Skip to content

Exempt running tools from the AI idle timeout - #221

Open
DavertMik wants to merge 4 commits into
mainfrom
fix/idle-timeout-exempts-running-tools
Open

DavertMik wants to merge 4 commits into
mainfrom
fix/idle-timeout-exempts-running-tools

Conversation

@DavertMik

Copy link
Copy Markdown
Contributor

Problem

Session UsualNegativeGray57 (trace 4892e780…) failed the scenario "Create a mixed manual and automated run and verify it appears in the runs list." — but the run was created: POST /api/…/runs 200, navigation to /runs/55c2d528. Every form() call still reported success: false, and Pilot voted fail on "Creation timed out".

I.fillField('Title', …) on the mixed-run form took 22.5s (step meta 1789530314391 → 1789530336908). That pushed the roundtrip past the 30s deadline armed in raceWithIdleTimeout, abortAfterIdle fired mid-tool, and withRetry (maxAttempts: 3) replayed the turn. Three invoke_agent spans overlap to the millisecond:

attempt start end level
1 03:44:48.802 03:46:03.796 DEFAULT
2 03:45:18.819 (+30.017s) 03:46:02.515 ERROR This operation was aborted
3 03:45:48.850 (+30.031s) 03:46:25.128 ERROR This operation was aborted

All three issued a creation form() into the one global CodeceptJS recorder. Attempt 1's commands ran (fillField OK → click All tests OK → click Save OK → run created); attempt 2's fillField then ran against the already-navigated detail page and threw. Because the recorder and attachStepLogger's event dispatcher are process-global, attempt 1's await recorder.promise() rejected on attempt 2's error — so action.lastError was set for a tool whose own commands had all succeeded.

The result was one payload containing both outcomes:

attempts: [OK fillField, OK click "All tests", OK click "Save", FAILED fillField]
pageDiff: { urlChanged: true, currentUrl: "/runs/55c2d528",
            requests: [POST /api/…/runs 200, …] }

Every I.step span in that window is duplicated in pairs — two step loggers attached at once. Pilot's notes carry Execution error: AI request timeout verbatim.

Fix

abortAfterIdle measured wall-clock elapsed over the entire roundtrip, tool execution included, and so read a slow tool as an idle model. Its one existing exemption — executionController.isAwaitingInput() — is this same bug already patched narrowly for a single blocking tool. This generalizes it to any tool:

  • withIdleExemption() wraps each tool's execute to increment/decrement a toolsRunning counter
  • abortAfterIdle re-arms while toolsRunning > 0, so the timer only fires when the model itself is what we are waiting on
  • applied once at the generateWithTools parameter, so both the withHarmonyChannelFallback path and the required-tool-choice relax path inherit it

27 lines, one file.

Considered and rejected

  • Dropping 'AI request timeout' from retryCondition. It is dead — the generic error.message.includes('timeout') above it already matches — and removing the generic match too would still leave the abort firing mid-fillField, with the zombie form colliding with whatever the next tester iteration sends into the same recorder. The retry amplifies; it is not the cause.
  • Re-arming on onStepEnd. Steps bracket model+tools together, so the timer would still have fired at 03:45:48 inside a tool that ended at 03:46:03.
  • Reading setActivity state. Activity is also set for the model call itself, so it cannot tell model-idle from tool-busy.
  • Touching Pilot or Tester. Both behaved correctly on the inputs they were given. askApi would indeed have proven the run existed, but it is registered on Pilot's supervision conversation while the verdict call has no tools — and the tester's belief was already false two minutes upstream.

Trade-off

A genuinely hung browser tool is no longer bounded by the AI idle timeout, only by Playwright's own setDefaultTimeout(config.action?.timeout ?? DEFAULT_ACTION_TIMEOUT) in Action.executeOnce. That is the right owner for a browser deadline, but a tool that hangs below Playwright's timeout now stalls the turn instead of being killed at 30s.

Verification

  • bun test tests/unit/ — 1423 pass, 0 fail
  • bun test tests/integration/ — 148 pass, 1 fail (Prima attaches to a live playwright-cli session, pre-existing and environmental; fails identically on main)
  • bun run check:fix — clean
  • tsc -p tsconfig.json — same 6 pre-existing provider.ts errors as main, none new

Replaying the scenario should show one invoke_agent span per tester iteration with no overlap, zero AI request timeout notes, the first form() returning success: true with POST /runs 200 + urlChanged, and no I.step spans emitted in duplicate pairs. This one needs a regression run to confirm end to end — flagging rather than labelling.

🤖 Generated with Claude Code

The 30s AI request timeout was armed as a wall-clock deadline over the
whole generateText roundtrip, tool execution included. A browser action
slower than the deadline — a fillField that took 22.5s on a busy form —
was read as an unresponsive model: the turn was aborted mid-tool and
withRetry replayed it up to three times while the original commands were
still executing.

All three attempts pushed commands into the one global CodeceptJS
recorder. The first attempt's commands ran and created the record; the
second attempt's fillField then ran against the already-navigated page
and threw, and because the recorder and its step logger are process-wide,
the first attempt's await rejected on the second's error. The form tool
returned attempts [OK, OK, OK, FAILED] alongside a pageDiff carrying
POST /runs 200 and the new URL — success and failure in one payload.
Tester believed creation had failed and Pilot voted fail on
"Creation timed out".

abortAfterIdle already exempted executionController.isAwaitingInput(),
the same fix applied narrowly to one blocking tool. Generalize it: count
in-flight tool executions and treat any of them as not-idle, so the timer
only fires while the model itself is what we are waiting on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/ai/provider.ts Outdated
return { ...tools, commentary: createHarmonyChannelFallbackTool() };
}

let toolsRunning = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This counter is process-global, so a tool running in one request disables the idle timeout for other concurrent requests too. It should be scoped to the individual generateWithTools call.

DavertMik and others added 3 commits September 17, 2026 00:49
…pts-running-tools

# Conflicts:
#	CHANGELOG.md
The counter was module-global, so a browser tool running under one
request suppressed the idle timeout for every other in-flight model call.
maxParallelRequests defaults to 4, and nested calls bypass the slot
limiter entirely (modelSlotContext short-circuits withModelRequestSlot) —
so an agent invoked from inside a Tester tool had its own idle timeout
held open by the tool that called it, for as long as that tool ran.

Give each generateWithTools call its own counter and thread it through
raceWithIdleTimeout to abortAfterIdle. A call now only exempts itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Private module-level helpers belong with the file's other private
helpers at the bottom, not above the class they serve — function
declarations hoist, so position was never a constraint. Record the rule
in CLAUDE.md alongside the existing one for private methods.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants