Skip to content

fix(hook): bound the soft read/search nudge per session - #3443

Open
L4XB wants to merge 1 commit into
Graphify-Labs:v8from
L4XB:fix/cap-soft-hook-nudges
Open

fix(hook): bound the soft read/search nudge per session#3443
L4XB wants to merge 1 commit into
Graphify-Labs:v8from
L4XB:fix/cap-soft-hook-nudges

Conversation

@L4XB

@L4XB L4XB commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #3435

Problem: _run_hook_guard writes _READ_NUDGE / _SEARCH_NUDGE on 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 ran graphify query is told on its very next read that it must run graphify 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 returns False while _query_stamp_fresh() (the agent ran query/explain/path within GRAPHIFY_HOOK_STRICT_TTL) and after GRAPHIFY_HOOK_NUDGE_CAP nudges in the same session (default 5; 0 or a non-number disables the cap). The count lives in graphify-out/cache/hook_sessions/<sid>.nudges, next to the strict-mode .denied markers, and read + search share one budget.
  • Calls without a session_id are not counted (no key to count on), and the guard keeps its fail-open contract: any error allows the nudge.
  • The 24h marker GC that only ran on the strict path is now _gc_hook_session_markers() and runs when a new session counter is created too.
  • Strict mode is unchanged: the once-per-session deny still fires first; the nudge that follows it is what gets bounded.
  • README: one sentence on the new bound next to the strict-mode note.

Tests: tests/test_hook_guard.py covers 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_deny now expects no output at all when the stamp is fresh (previously the soft nudge), which is the intended behaviour change.

pytest tests/test_hook_guard.py tests/test_hook_strict.py tests/test_hook_guard_token_match.py tests/test_hook_out_of_project_paths.py
154 passed, 4 skipped
ruff check: clean

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.

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

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — impact, full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/test_csharp_enum_members.py — full-run-safety
  • tests/test_csharp_field_generic_args.py — full-run-safety
  • tests/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).

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.

PreToolUse soft nudge has no session cap — measured 8,348 injections (~651k tokens) in a single project

1 participant