Skip to content

feat(core): give every tool call a deadline and make a wedged tool interruptible [K-05] - #8

Merged
haydarkadioglu merged 1 commit into
mainfrom
feat/K-05-tool-deadline
Oct 6, 2026
Merged

haydarkadioglu merged 1 commit into
mainfrom
feat/K-05-tool-deadline

Conversation

@haydarkadioglu

Copy link
Copy Markdown
Owner

Summary
Every tool call in the conversation loop now runs on a polled daemon worker with a per-call deadline, so a tool that never returns can no longer block the turn forever, and interrupt() (which only sets Agent._cancel) can actually break a wedged tool.

Why
Koza executed each sequential tool inline (self._execute_tool(name, args) at core.py:1436 pre-change) and only checked self._cancel between calls. A tool that hangs — a subprocess with no timeout, a socket read that never returns — held the loop indefinitely, and /stop did nothing until it returned. The parallel branch had the mirror-image bug: with ThreadPoolExecutor(...) as executor: joins its workers on exit, so one hung call froze the whole batch even after the cancel poll broke out of the wait loop.

Measured on main with a probe driving one turn whose only tool call blocks forever (scratch script, not committed):

UNFIXED(main): HUNG — still blocked after 3.0s (no deadline, not interruptible)

What changed

  • core.py:38 — new module constants _SEQUENTIAL_INTERRUPT_POLL_SECONDS = 0.1, _DEFAULT_TOOL_TIMEOUT_SECONDS = 600.0, _TOOL_DEADLINE_EXEMPT_TOOLS (delegate_task, spawn_subagent, start_coding_session, start_tracked_coding_task, browser_task — they supervise their own liveness) and helpers _resolve_tool_timeout(), _tool_timeout_message(), _tool_interrupt_message().
  • core.py:1702 — Agent._run_tool_with_deadline(): runs one call on a daemon worker, polls in 0.1 s slices, returns (result, elapsed, outcome) with outcome done | timeout | interrupted. A disabled deadline still polls — that poll is what makes a non-cooperative tool interruptible.
  • core.py:1498 — sequential branch: the inline call is replaced by the helper; an interrupted outcome stops the turn and marks any remaining calls Process interrupted by user.
  • core.py:1352 — parallel branch: the executor is replaced by one daemon worker per call reporting through a queue; the collect loop honours interruption and the per-call deadline and abandons overrunning calls.
  • config.py:98 — new tool_timeout_seconds: 600 (0 disables the deadline).
  • tests/test_tool_deadline.py (new, force-added — tests/ is gitignored): 9 tests (unit + end-to-end throughput the loop).

Provenance
Mechanism ported from hermes:agent/tool_executor.py:834-841, 865-892, 957-971 (_SEQUENTIAL_INTERRUPT_POLL_SECONDS, _poll_sequential_future, _resolve_sequential_tool_timeout, _SEQUENTIAL_DEADLINE_EXEMPT_TOOLS), re-implemented with koza names, config key and logging. Upstream exempts delegate_task/manage_connections; koza's equivalents are the four above plus browser_task.

Verification

python -m pytest tests -q -p no:cacheprovider   ->  53 passed, 3 warnings in 30.71s
                                                    (main baseline before this change: 44 passed)
ruff check .                                     ->  Found 1990 errors   (main: 1991 — no new errors)
ruff check core.py                               ->  68 (main: 69 errors); tests/test_tool_deadline.py clean

Test-power check (scratch probe, not committed — same turn, main's core.py vs the fix):

UNFIXED(main): HUNG — still blocked after 3.0s (no deadline, not interruptible)
FIXED:         returned in 0.41s, rounds=2, tool_results=["Error executing tool 'run_command': timed out after 0.3s and ..."]

Risk / rollback

  • Behaviour change: a call that genuinely runs longer than tool_timeout_seconds (default 600 s) is now abandoned with a timeout result instead of running to completion — raise the config value, or add the tool to _TOOL_DEADLINE_EXEMPT_TOOLS.
  • An abandoned tool is not killed (a Python thread cannot be), only no longer awaited; its worker is a daemon thread, so it cannot block interpreter exit.
  • Rollback: revert this commit — additive config key, no API change, no migration.

Roadmap: K-05

@haydarkadioglu
haydarkadioglu merged commit e75eccc into main Oct 6, 2026
0 of 2 checks passed
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.

1 participant