test: isolate the tool-definition caches between tests - #75837
test: isolate the tool-definition caches between tests#75837bbasketballer75 wants to merge 2 commits into
Conversation
test_background_review_installs_thread_local_whitelist fails intermittently
in CI with:
AssertionError: assert 'memory' in {'skill_manage'}
while passing in isolation, in its own file, and on rerun. It is not a stale
CI cache — it is order-dependent process state.
The whitelist under test comes from
get_tool_definitions(enabled_toolsets=["memory", "skills"]), which depends on
two module-level caches that outlive a single test:
* tools.registry._check_fn_cache — per-check_fn verdicts, 30 s TTL, keyed by
function object. An earlier test that probes a memory/skills check_fn
while the feature looks unavailable stamps False in for the next 30 s.
* model_tools._tool_defs_cache — memoized definition lists. Its key covers
registry._generation and the config fingerprint but NOT the check_fn
verdicts resolved underneath, so a poisoned entry is invisible to it.
Reproduced deterministically by stamping False into the TTL cache: memory,
skill_view and skills_list all drop out of the computed toolset, and the test
fails with exactly the CI assertion. Restoring the entries makes it pass.
Adds a suite-wide autouse fixture that drops the False verdicts (a cached
True cannot produce this failure mode, and keeping those avoids re-probing
every available tool) and clears the definition memo, which must go entirely
because its key cannot see the verdicts.
tests/test_get_tool_definitions_cache_isolation.py already established this
pattern per-file for _tool_defs_cache; this lifts it suite-wide and adds the
check_fn cache, which is the one carrying the poisoned verdict. 116 test
files touch tool definitions or the registry, so per-file fixtures could not
cover the exposure.
Verified against tests/run_agent/ + tests/tools/test_registry.py: identical
pass/fail counts before and after (21 pre-existing Windows-only failures
unchanged), and the poisoned-neighbour reproduction now passes.
There was a problem hiding this comment.
Pull request overview
This PR addresses an intermittent, order-dependent CI failure caused by process-global tool-definition caching leaking across tests. It adds a suite-wide autouse fixture to reset tool-definition caches so model_tools.get_tool_definitions() results are consistent regardless of test order.
Changes:
- Add an autouse pytest fixture in
tests/conftest.pyto clear tool-definition caches before and after each test. - Clear
model_tools._tool_defs_cacheand prunetools.registrycheck-function verdict caching to prevent “poisoned” toolsets.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _clear(): | ||
| try: | ||
| from tools import registry | ||
|
|
||
| # Drop only the ``False`` verdicts. A cached ``False`` is what makes | ||
| # a tool silently vanish from a computed toolset; a cached ``True`` | ||
| # cannot produce that failure mode. Keeping the ``True`` entries | ||
| # avoids re-probing every available tool's ``check_fn`` on the next | ||
| # use, which is where the real cost of a blanket clear lives. | ||
| with registry._check_fn_cache_lock: | ||
| for fn, entry in list(registry._check_fn_cache.items()): | ||
| if not entry[1]: | ||
| registry._check_fn_cache.pop(fn, None) | ||
| except Exception: | ||
| pass | ||
| try: | ||
| import model_tools | ||
|
|
||
| # Must still go entirely: its key does not cover the verdicts | ||
| # above, so an entry computed from a poisoned ``False`` would | ||
| # survive and keep serving the short list. | ||
| model_tools._tool_defs_cache.clear() | ||
| except Exception: | ||
| pass |
Measured on tests/run_agent/ + tests/tools/test_registry.py (1271 passing): no fixture 515 s blanket clear 606 s clear False verdicts 602 s The "drop only False verdicts" variant was written on the theory that re-probing available tools' check_fns was the expense. It is not — it saved 4 s, inside noise. The cost is dominated by rebuilding _tool_defs_cache, which cannot be preserved because its key does not cover the verdicts underneath it. Reverted to the simpler blanket clear (same cost, less to explain) and recorded the numbers in the docstring so the next person does not retry the same optimization.
|
Measured the runtime cost, since a suite-wide autouse fixture deserves a number rather than an assurance. On
So roughly +17% on a deliberately tool-heavy subset; the full suite dilutes well below that. Pass/fail counts are identical across all three (21 pre-existing Windows-only failures unchanged). I'd initially written the third variant on the theory that re-probing available tools' If +17% is too steep for the value, the fallback is a per-file fixture on 🤖 Measured by Claude Code |
|
Thanks for the careful cache analysis and for measuring the suite-wide fixture cost. This is an automated hermes-sweeper review. The reported cross-file test-state failure is already addressed on current main:
The proposed suite-wide per-test cache clearing therefore duplicates an existing isolation guarantee while adding the measured test-runtime cost. |
The symptom
tests/run_agent/test_background_review_toolset_restriction.py::test_background_review_installs_thread_local_whitelistfails intermittently in CI with:It passes in isolation, passes running its own file, and passes on rerun — the classic shape of order-dependent process state rather than a bad test or a stale CI cache.
Root cause
The whitelist under test is derived from
get_tool_definitions(enabled_toolsets=["memory", "skills"]), which depends on two module-level caches that outlive any single test:tools.registry._check_fn_cachecheck_fnverdicts, 30 s TTL, keyed by function objectcheck_fnwhile that feature looks unavailable stampsFalsein for the next 30 smodel_tools._tool_defs_cacheregistry._generationand the config fingerprint, but not thecheck_fnverdicts resolved underneath — so a poisoned entry is invisible to itReproduced deterministically by stamping
Falseinto the TTL cache and recomputing:memory,skill_viewandskills_listall drop out of the toolset, and the test fails with exactly the CI assertion. Restoring them makes it pass. Wiring a poisoning test in as a neighbour reproduces the CI failure on demand.The fix
A suite-wide autouse fixture in
tests/conftest.pythat:Falseverdicts from_check_fn_cache— a cachedTruecannot cause a tool to vanish, and keeping those avoids re-probing every available tool'scheck_fn;_tool_defs_cacheentirely, since its key cannot see the verdicts underneath it.tests/test_get_tool_definitions_cache_isolation.pyalready established this pattern per-file for_tool_defs_cache. This lifts it suite-wide and adds thecheck_fncache, which is the one actually carrying the poisoned verdict. Per-file fixtures couldn't cover the exposure — 116 test files touch tool definitions or the registry.tests/conftest.pyis already where this class of problem is handled; the fixture immediately above this one fixes a structurally identical order-dependent leak in the computer-use approval callback.Verification
tests/run_agent/+tests/tools/test_registry.py: identical pass/fail counts before and after (1271 passed, 21 pre-existing Windows-only failures unchanged, 3 skipped).tests/test_get_tool_definitions_cache_isolation.pystill passes.Found while investigating why #15's CI slice 4/8 was red. It's unrelated to that PR — this PR touches none of
run_agent,background_review,model_tools, or the registry — so it's split out here rather than bundled.🤖 Generated with Claude Code