Skip to content

Navigation Menu

Sign in
Sign up

feat(shell): adaptive timeouts and a wait-for-condition primitive - #3402

Open
kovtcharov-amd wants to merge 4 commits into
main from
kalin/3382-adaptive-shell-timeouts
Open

feat(shell): adaptive timeouts and a wait-for-condition primitive #3402
kovtcharov-amd wants to merge 4 commits into
main from
kalin/3382-adaptive-shell-timeouts

Conversation

@kovtcharov-amd

@kovtcharov-amd kovtcharov-amd commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The agent could not run anything that takes minutes. A shell command was killed at 30 seconds no matter what it was, so a test suite, a build or an install never finished — and a timed-out command on Windows did not even stop: a verified 5-second timeout took over two minutes and never returned, because the kill missed the grandchild still holding the pipes. Waiting was just as bad: with no way to wait for a condition, the agent slept and re-checked, which is what a user sees as spinning and what burns the loop's step budget. Now the timeout comes from what the command is — 900s for test runners, 1800s for builds and installs, 300s for git/network calls, 30s for everything else — the kill actually fires, and wait_for_condition waits for a server to answer or a file to appear in one step instead of one per check.

Closes #3382.

Needs a rebase once #3394 (bypass mode) and #3380 (persistent shell) land — all three touch shell_tools.py. The diff there was kept as small as the change allows; the class table lives in its own module.

🔍 Technical details
  • The class table is src/gaia/agents/tools/command_timeouts.py — four named classes, one default each, plus the classifier. Wrappers are transparent (python -m pytest, uv run pytest, npx jest), and a pipeline takes its longest segment.
  • An explicit timeout= still wins; out of range is refused, not clamped, so a command is never killed at a limit its caller did not choose.
  • The signal reaches the model through the docstring: @tool's description=/parameters= kwargs are swallowed and ignored, and the non-native prompt path renders only the first line of __doc__. A test fails if a class default drifts out of that line.
  • The executor now drives Popen and kills the process tree on expiry (taskkill /T / process group), then reads the buffered output with a grace period. Partial output is truncated to 10K like the success path — the live run returned 806KB before that.
  • wait_for_condition is confirmation-gated, runs its predicate through run_shell_command (same allowlist, refused before the prompt), is charged once against the shell rate limit rather than once per probe, and waits on the agent's cancel event so Stop lands immediately.

Test plan

  • python -m pytest tests/unit/test_shell_adaptive_timeouts.py tests/unit/test_shell_guardrails.py tests/unit/test_shell_output_encoding.py tests/unit/test_skill_binary_grants.py -q — 376 pass. Covers each timeout class end-to-end to subprocess, the wait primitive's deadline expiry, and the three already-true behaviours (applied timeout returned, timed_out flag, partial output on timeout).
  • python -m pytest hub/agents/gaia/python/tests/test_full_tool_bundles.py -q — the new tool is in the shell bundle, so the flagship's per-turn selector can surface it.
  • python util/lint.py --all — clean.
  • Live check on Windows, real subprocesses: a command that previously hung on timeout returns in 5.1s with its partial output; a wait for a file created after 8s returns at 11.7s after 3 polls and one rate-limit charge; deadline expiry and a refused predicate both stop immediately.
  • gaia eval agent --category tool_selection on Gemma-4-E4B — judged pass rate unchanged vs the gemma-4-e4b-d71cd914 baseline (75% → 75%), avg score 8.4 → 8.7. known_path_read went FAIL → PASS. smart_discovery failed for an environment reason, not this change: the agent called no tools and refused over a path from another worktree left in this box's memory store. The elapsed-time flags are a loaded dev box, not the change.
  • Expected red: the rag_quality + context_retention + tool_selection check — Eval gate fails identically on main: tool_selection scenarios die in 3s and claude's stderr is swallowed #3341 tracks it failing identically on main.

Ovtcharov added 4 commits September 5, 2026 04:15
...it primitive
A shell command was killed at 30 seconds no matter what it was, so a test
suite, a build or an install never finished — and while `timeout` was already
a parameter the model could raise, nothing told it how long the command it was
about to run should take, so it almost never did.
The timeout now comes from the command itself: test runners get 900s, builds
and installs 1800s, VCS and network calls 300s, everything else the unchanged
30s. The applied value and its class come back in the result, and an explicit
`timeout=` still wins. Out-of-range values are refused rather than clamped, so
a command is never killed at a limit its caller did not choose.
`wait_for_condition` replaces sleep-and-recheck: it polls a predicate against a
monotonic deadline inside one call, so waiting for a server to answer or a
build to land costs one agent step instead of one per check. It is bounded
(600s ceiling, 5-60s poll interval), gated behind the same confirmation and
guardrails as the command it polls with, charged once against the shell rate
limit, and interrupted by the agent's cancel signal.
The tool registry takes a tool's description from its docstring — @tool's
`description=` and `parameters=` kwargs are swallowed and ignored — and the
non-native prompt path renders only the first line of it. The class defaults
were in the ignored kwarg, so the model would never have seen them. They now
lead the docstring, with a test that fails if a class default drifts out of
that line.
Also documents both tools in the mixin spec and the flagship's tool surface.
...eturns
A 5-second timeout took over two minutes and never returned: `subprocess.run`
kills only the process it launched, then re-enters `communicate()` with no
timeout, so a surviving grandchild holding the pipes blocks the call for as
long as it lives. With 30-minute build timeouts now reachable, that hang would
have held the agent loop for an hour instead of three minutes.
The executor now drives Popen itself and kills the whole tree on expiry —
`taskkill /T` on Windows, the process group on POSIX — then reads the buffered
output with a short grace. Verified live: the same command that hung returns in
5.1s with its partial output intact.
That partial output is also truncated to 10K now, like the success path always
was. A command killed at 30 minutes has printed far more than one killed at 30
seconds; the live run returned 806KB straight into the model's context.
The flagship shows the model a subset of its registry per turn, chosen by
bundle. A tool in no bundle is registered but unreachable, so wait_for_condition
joins the shell bundle in both the chat and full profiles.
Also repoints the granted-CLI argv tests at Popen, which is what the executor
drives now — the behaviour they pin (a granted binary never goes through
cmd.exe) is unchanged.
@github-actions github-actions Bot added documentation Documentation changes tests Test changes agents labels Sep 5, 2026

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Verdict: Request changes

This gives shell commands a timeout that matches what they are (test runs get 15 minutes, builds 30, network calls 5, everything else 30 seconds), makes a timed-out command actually die instead of hanging on a surviving grandchild, and adds a wait_for_condition primitive so waiting costs one agent step instead of sixty. The core work is careful and well-tested — but it registers a new tool without updating the tool counts the packages publish, which breaks a CI drift guard.

What needs fixing before merge:

  1. The new tool isn't counted anywhere except the docs. Adding wait_for_condition grows every chat profile's tool registry by one. The guide was updated from 67 to 68, but the two shipped package manifests and the three per-profile registration counts still declare the old numbers. There are tests that compare those declared numbers against the real registry, so this fails CI — and the flagship's published manifest would under-report what it offers.

  2. Stop no longer interrupts a running shell command. The per-tool guard was raised from 3 minutes to just over an hour so a long build isn't abandoned mid-run — but nothing checks the cancel signal while the command runs. Before this change a user clicking Stop waited at most half a minute; now they wait until a 30-minute build finishes. wait_for_condition already does this right by waiting on the cancel event; run_shell_command should do the same and kill the process tree when it fires.

  3. The longer timeout classes can't be reached by default, and the guide implies they can. The shell allowlist is read-only — pytest, pip, npm, make, curl, docker and git clone/fetch/pull/push are all refused unless a loaded skill grants that binary. So today essentially every command still lands in the 30-second class. The new guide paragraph promising 15-minute test runs sits one paragraph after the sentence saying installs and arbitrary binaries are refused, which reads as a contradiction. Say the longer classes need a skill grant, and add one test that drives a granted binary through the real path — every classification test currently disables the allowlist.

Real-world evidence

The automated evidence harness failed to run on this PR (evidence-bundle.md reports an error before producing any evidence), so the changed surface was not exercised here and this verdict rests on static review alone. The PR description does describe a live Windows run and a tool_selection eval, but the harness did not confirm either and every test-plan box is unchecked. Given finding 1 predicts a CI failure, please confirm the full unit suite is green before merge.

🔍 Technical details

🔴 Critical

tools_count drift — new tool registered, every literal left stale (hub/agents/gaia/python/gaia_agent/__init__.py:77)

register_shell_tools() now registers two tools (run_shell_command, wait_for_condition), and every ChatAgent profile calls it unconditionally — including the early-return chat profile (hub/agents/chat/python/gaia_agent_chat/agent.py:1295,1300). So every profile's real registry size is +1. The docs moved 67→68; these did not:

Location Declared Should be
hub/agents/gaia/python/gaia-agent.yaml:23 67 68
hub/agents/gaia/python/gaia_agent/__init__.py:77 67 68
hub/agents/chat/python/gaia-agent.yaml:14 54 55
hub/agents/chat/python/gaia_agent_chat/__init__.py:93 (build_chat) 1 2
hub/agents/chat/python/gaia_agent_chat/__init__.py:122 (build_doc) 37 38
hub/agents/chat/python/gaia_agent_chat/__init__.py:152 (build_file) 33 34

tests/unit/test_chat_fix_contracts.py::test_yaml_top_level_tools_count_matches_default_profile_registry and ::test_registration_tools_count_matches_real_registry introspect the registry and assert against these literals — all four assertions fail as-is. The comment at gaia_agent/__init__.py:76 states the invariant explicitly ("Must equal the real registry size for the default construction, and the manifest's own tools_count"). Neither file is in the PR's test plan.

 tools_count=68,

🟡 Important

Cancel is not observed during a long run_shell_command (src/gaia/agents/tools/shell_tools.py:812)

The @tool(timeout=MAX_COMMAND_TIMEOUT + 60) override raises the per-tool guard to 3660s. Agent._call_tool_bounded (src/gaia/agents/base/agent.py:3159) joins the worker thread for that whole window, and the cancel event is only checked between steps (agent.py:4596) and per generated token (agent.py:5001) — never inside a tool body. process.communicate(timeout=timeout) blocks for the full class default, so Stop during a build-class command is honoured up to 1800s late (3600s with an explicit timeout=). Pre-PR worst case was the flat 30s.

_wait_interrupt_signal() already resolves the right event; reuse it here — wait on the process in slices and terminate_process_tree(process) when the event fires, returning the same cancelled: True shape wait_for_condition uses.

The non-default classes are unreachable through the shipped allowlist, and no test covers the reachable path (src/gaia/agents/tools/shell_tools.py:44, tests/unit/test_shell_adaptive_timeouts.py:1274)

ALLOWED_COMMANDS contains no pytest/pip/npm/make/cmake/curl/wget/docker, and SAFE_GIT_COMMANDS excludes clone/fetch/pull/push — so test, build and network only apply to a binary a loaded skill granted via shell:execute:<binary>. The unrestricted fixture concedes exactly this ("it refuses pytest and pip install outright today — so with it in place only the default class would ever be reachable") and every classification test monkeypatches _validate_command away. That leaves the feature's real path — a granted binary classifying as build/test and reaching subprocess with the longer timeout — untested end to end. One test with _granted_binaries populated would close it.

Same gap in the docs: docs/guides/gaia.mdx:106 promises "a test run gets 15 minutes, a build or install 30" directly under gaia.mdx:104's "Anything that writes, installs, or executes an arbitrary binary is refused." Add the grant caveat.

🟢 Minor

  • normalize_binary name collision (src/gaia/agents/tools/command_timeouts.py:454) — gaia.skills.binaries.normalize_binary deliberately returns "" for a path spelling so ./gh can never match a grant; this one strips the path so /opt/evil/pytest classifies as test. Divergent security semantics under one name invites importing the wrong one. Suggest command_basename.
  • _split_segments duplicates _split_pipeline (command_timeouts.py:501 vs shell_tools.py:286) — same pipeline split, two implementations. Extract one.
  • The description= / parameters= block on wait_for_condition is dead (shell_tools.py:959-1000) — @tool accepts them into **kwargs and never reads them (src/gaia/agents/base/tools.py:25); the registry takes description from __doc__. Forty lines that can silently disagree with the docstring that is actually used. (Matches the pre-existing style on run_shell_command, so a sweep, not a blocker.)
  • A pipeline without spaces misclassifies (command_timeouts.py:501) — _split_segments only recognises | as a standalone token, so ls|pytest classifies as default (30s). shlex keeps it as one token.
  • truncated is inferred by comparing strings (shell_tools.py:1147) — stdout != (stdout_str or "") works but is indirect. Have _truncate return (text, was_truncated).

Strengths

  • The process-tree kill is proven, not asserted. test_a_blown_deadline_really_kills_the_process spawns a real child and measures the kill latency — exactly the class of bug (subprocess.run re-entering communicate() with no timeout) that a mocked test could never catch.
  • Refuse-not-clamp on an out-of-range timeout, with an error naming the ceiling and the alternative. Silently clamping would produce a command killed at a limit nobody chose — the failure mode the Fail-Loudly rule exists for.
  • test_the_docstring_states_every_class guards the one line the model actually sees. Encoding "the non-native prompt path renders only the first line of __doc__" as a test is the right way to keep a prompt-visible table honest.
  • wait_for_condition's safety wiring is complete and consistent — confirmation-gated (agent.py:203), grant-scoped so "always allow" binds to the command not the tool (tool_grants.py:90), refused before the prompt via policy_refusal_for_call, deliberately not grant-exempt, and charged once against the rate limit rather than per probe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

agents documentation Documentation changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

feat(shell): adaptive timeouts and a wait-for-condition primitive

1 participant

AltStyle によって変換されたページ (->オリジナル) /