fix(hook): bound the soft read/search nudge per session - #3443
Conversation
The PreToolUse guard re-injected the MANDATORY read/search nudge on every qualifying Read/Glob/Grep/Bash call with no cap and no check whether the agent had just queried the graph. Only the opt-in strict deny was capped. One project measured 8,348 injections (~651k tokens) for 339 graphify calls, several times the cost of every query they were asking for. Skip the soft nudge while the query stamp is fresh (the agent is already oriented) and cap it at GRAPHIFY_HOOK_NUDGE_CAP nudges per session (default 5, 0 disables), counted in a per-session marker next to the strict-mode ones. Calls without a session id are uncounted, the guard still fails open, and the shared marker GC now runs on both paths. Fixes Graphify-Labs#3435
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 2 change(s) tested, no difference found (not proven).
Graphify review — findings
Bounds the soft Read/Grep/Bash nudge so it no longer re-injects on every qualifying tool call: _nudge_allowed skips it while a recent graphify query/explain/path stamp shows the agent is already oriented, and otherwise caps it at GRAPHIFY_HOOK_NUDGE_CAP emissions per session (default 5; 0 or a non-number disables the cap), tracked in a per-session .nudges marker that read and search calls share. Sessions without an id are never counted, marker errors fail open (nudge shown), and stale-marker GC is factored into _gc_hook_session_markers and reused by both the nudge and strict-block paths. A fresh query stamp in strict mode now also suppresses the fallback nudge, so a recently-oriented read produces no output at all.
No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 338 functions depend on the 146 functions this change touches.
Health — this change adds coupling hotspots:
- new:
dispatch_command()— 2 callers, 124 callees - new:
_stale_graph_sources()— 7 callers, 6 callees - new:
_run_hook_guard()— 4 callers, 9 callees - new:
test_poisoned_manifest_is_healed()— 0 callers, 6 callees
Verification — 338 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 284 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
262 of 262 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— full-run-safetytests/test_astro_import_ids.py— full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— full-run-safetytests/test_benchmark_raw_graph.py— full-run-safetytests/test_build.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_codebuddy.py— impact, full-run-safetytests/test_community_hub_labels.py— full-run-safetytests/test_community_labels_skill.py— full-run-safetytests/test_confidence.py— full-run-safetytests/test_corrupt_graph_json.py— full-run-safetytests/test_cpp_nested_and_cli.py— full-run-safetytests/test_cpp_objc_cross_file_calls.py— full-run-safetytests/test_cpp_preprocess.py— full-run-safetytests/test_cross_extension_reexport_self_cycle.py— full-run-safetytests/test_cross_language_call_resolution.py— full-run-safetytests/test_cross_repo_member_calls.py— full-run-safetytests/test_cross_repo_shared_types.py— full-run-safetytests/test_csharp_call_site_generic_args.py— full-run-safetytests/test_csharp_enum_members.py— full-run-safetytests/test_csharp_field_generic_args.py— full-run-safetytests/test_csharp_generic_callsites.py— full-run-safety- … and 212 more
non-code file(s) changed (
README.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
README.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
No difference found (not proven): No behavior difference found in \_mark\_session\_denied (not a proof).
The verifier ran both versions of \_mark\_session\_denied on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_run\_hook\_guard (not a proof).
The verifier ran both versions of \_run\_hook\_guard on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 4 more finding(s) on lines outside this diff (see the check run).
Fixes #3435
Problem:
_run_hook_guardwrites_READ_NUDGE/_SEARCH_NUDGEon every qualifying Read/Glob/Grep/Bash call. There is no dedup, no session cap, and the "recently oriented" query stamp only suppresses the opt-in strict deny, so an agent that just rangraphify queryis told on its very next read that it must rungraphify query. The issue measured 8,348 injections (~651k tokens) for 339 graphify calls in one project.Change (
graphify/cli.py):_nudge_allowed(session_id)gates both soft nudges. It returnsFalsewhile_query_stamp_fresh()(the agent ranquery/explain/pathwithinGRAPHIFY_HOOK_STRICT_TTL) and afterGRAPHIFY_HOOK_NUDGE_CAPnudges in the same session (default 5;0or a non-number disables the cap). The count lives ingraphify-out/cache/hook_sessions/<sid>.nudges, next to the strict-mode.deniedmarkers, and read + search share one budget.session_idare not counted (no key to count on), and the guard keeps its fail-open contract: any error allows the nudge._gc_hook_session_markers()and runs when a new session counter is created too.Tests:
tests/test_hook_guard.pycovers stamp suppression (read + search), the per-session cap with independent sessions, the shared read/search budget, the default of 5, the disable values, and uncounted calls without a session id.tests/test_hook_strict.py::test_fresh_query_stamp_suppresses_denynow expects no output at all when the stamp is fresh (previously the soft nudge), which is the intended behaviour change.The rest of the suite: 5118 passed locally; the 24 failures are environmental in my checkout (missing optional
openai/tree-sitter-hcl, shallow git history for the skillgen tests, a wheel-build test) and don't touch the hook code.