Skip to content

fix(hooks): hooks-tool-dedupe fails protocol_compliance via top-level hooks block - #326

Merged
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
fix/hooks-tool-dedupe-load
Aug 27, 2026
Merged

fix(hooks): hooks-tool-dedupe fails protocol_compliance via top-level hooks block#326
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
fix/hooks-tool-dedupe-load

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

Root cause

Confirmed at amplifier-core 1.6.1, python/amplifier_core/loader.py:

  • ModuleLoader._get_module_metadata() (loader.py:504-566) tries an explicit __amplifier_module_type__ attribute on the module first; when absent (true of every in-tree foundation hook module — none declare it), it falls back to _guess_from_naming() (loader.py:568-602).

  • _guess_from_naming() does a first-match substring scan over a fixed keyword insertion order (loader.py:586-599): orchestrat, loop, provider, tool, hook, context.

  • hooks-tool-dedupe contains both "tool" and "hook" as substrings. Because "tool" is checked before "hook" in that dict, the module was classified as type="tool" and validated by ToolValidator instead of HookValidator.

  • ToolValidator._check_protocol_compliance (validation/tool.py:277-284) requires mount() to either populate coordinator.mount_points["tools"] or return an object with .name/.description/.execute. hooks-tool-dedupe.mount() correctly does neither (it registers a tool:post hook handler and returns a plain descriptor dict, exactly like its 5 working siblings) — so validation fails with exactly the reported error:

    ModuleValidationError: protocol_compliance: No tool was mounted and mount() did not return a Tool instance
    

Empirical proof (against amplifier-core 1.6.1's real loader):

>>> loader._guess_from_naming('hooks-tool-dedupe')
('tool', 'tools')
>>> loader._guess_from_naming('hooks-dedupe')
('hook', 'hooks')

None of the 5 working siblings (hooks-deprecation, hooks-process-guard, hooks-progress-monitor, hooks-session-naming, hooks-todo-display) contain a keyword the scan checks before "hook", which is why only this module was affected — and why it affects both composition paths that reference it (the top-level hooks: block and behaviors/agents.yaml's hooks: block): both resolve the module id through the identical loader.load() -> _validate_module() -> _get_module_metadata() call chain, so there is only one code path to fix, not two.

Fix

Renamed the module hooks-tool-dedupe -> hooks-dedupe rather than touching amplifier-core's naming heuristic — the heuristic is a documented, working fallback that every other hook module in this repo relies on correctly; this module's own name was the defect (per the discovered-root-cause guidance: prefer a rename when a name trips a core name-heuristic, over changing core).

Changed, via git mv + reference updates (no functional/behavioral changes):

  • modules/hooks-tool-dedupe/ -> modules/hooks-dedupe/
  • Python package amplifier_module_hooks_tool_dedupe -> amplifier_module_hooks_dedupe
  • pyproject.toml: package name, entry-point name (hooks-tool-dedupe -> hooks-dedupe), wheel package list
  • uv.lock: self-referential package name
  • Internal ToolDedupeHook class -> DedupeHook (not part of the classification surface, but kept consistent with the module's new name)
  • mount() registration name, contributor-channel name, and returned descriptor's "name" field
  • behaviors/agents.yaml: module id and git source subdirectory= path
  • The module's own 25 tests (imports, class name, assertions on registered/returned names)

Not a core bug in the sense of needing a core fix: the naming heuristic works correctly for every other hook module in this repo. It's a real footgun worth flagging upstream, though — see "Core follow-up" below.

Regression test

Added tests/test_hook_module_classification.py (new — no loader-classification test existed in foundation before this):

  • test_hook_module_classifies_as_hook — parametrized across every hooks-* module directory (discovered dynamically), calls amplifier-core's real ModuleLoader._get_module_metadata() against each module's actual on-disk path and asserts type == "hook". This is the generic guard against a future hook module picking a name with a colliding keyword.
  • test_hooks_dedupe_passes_real_validation — the specific end-to-end reproduction of the reported bug: calls the real ModuleLoader._validate_module() (the exact call site inside load() that raised ModuleValidationError) against hooks-dedupe's real on-disk module, and asserts it does not raise. This exercises the actual classification -> validator-selection -> mount() chain, unlike the module's existing tests (which call mount() directly against a fake coordinator and would not have caught this class of bug, since the bug is in classification/validator-selection before mount() is ever reached).

Smoke-test evidence (the original failure, reproduced pre-fix)

$ uv run python -c "
from amplifier_core.loader import ModuleLoader
loader = ModuleLoader()
print(loader._guess_from_naming('hooks-tool-dedupe'))
print(loader._guess_from_naming('hooks-dedupe'))
"
('tool', 'tools')   # pre-rename id: misclassified as a tool
('hook', 'hooks')   # post-rename id: classified correctly

Test results

  • tests/test_hook_module_classification.py (new, 8 tests): 8 passed
  • modules/hooks-dedupe/tests/ (existing 25 tests, updated for rename): 25 passed
  • Full repo suite (uv run pytest tests/ -q --tb=short, matches CI's .github/workflows/ci.yml invocation): 1642 passed, 1 skipped (1634 pre-existing + 8 new)
  • python_check (ruff format/lint, pyright, stub-check) on all changed files: clean

Core follow-up (not fixed here, per scope)

ModuleLoader._guess_from_naming()'s first-match-wins keyword scan (loader.py:586-599) is a latent footgun for any hook (or context/provider) module whose id happens to contain "tool", "loop", "provider", or "orchestrat" as a substring before "hook"/"context" is reached — this PR works around it for this one module by renaming, but the underlying ambiguity in amplifier-core remains. Worth an upstream fix (e.g. require modules to declare __amplifier_module_type__ explicitly, or make the scan match on hyphen-delimited tokens instead of raw substrings) tracked separately in amplifier-core.

Fixes: openai_improvement-j3t

amplifier-core's ModuleLoader classifies a module's type by trying an
explicit __amplifier_module_type__ attribute first, then falling back to
_guess_from_naming() (loader.py) when absent -- true for every in-tree
foundation hook module, including this one. That fallback does a
first-match substring scan over a fixed keyword order: orchestrat, loop,
provider, tool, hook, context. 'hooks-tool-dedupe' contains both 'tool'
and 'hook', and 'tool' is checked first, so the module was silently
classified as type='tool' and validated with ToolValidator instead of
HookValidator -- which fails protocol_compliance because mount() returns
a plain descriptor dict, not a Tool-shaped object with name/description/
execute, and never registers anything under coordinator.mount_points
['tools']:

  ModuleValidationError: protocol_compliance: No tool was mounted and
  mount() did not return a Tool instance

This is non-fatal (amplifier-core logs and skips the module, the session
completes), so the hook silently never activates via either composition
path that references it: the top-level hooks: block and behaviors/
agents.yaml's hooks: block both resolve the module id through the exact
same loader.load() -> _validate_module() -> _get_module_metadata() call
chain, so both were equally affected.

Confirmed empirically against amplifier-core 1.6.1:

  loader._guess_from_naming('hooks-tool-dedupe') -> ('tool', 'tools')
  loader._guess_from_naming('hooks-dedupe')       -> ('hook', 'hooks')

None of the 5 working sibling hook modules (hooks-deprecation, hooks-
process-guard, hooks-progress-monitor, hooks-session-naming, hooks-todo-
display) contain a keyword that the naming-fallback checks before 'hook',
which is why only this module was affected.

Fix: rename the module (hooks-tool-dedupe -> hooks-dedupe) rather than
touch amplifier-core's naming heuristic, since the heuristic is a
documented, working fallback for every other hook module in this repo --
the module's own name was the defect. Updates the module directory,
Python package, pyproject.toml (name/entry-point/wheel packages), uv.lock,
the internal DedupeHook class (was ToolDedupeHook), behaviors/agents.yaml's
module id and git source subdirectory, and the module's own 25 tests.

Adds tests/test_hook_module_classification.py: a foundation-level
regression test that calls amplifier-core's real ModuleLoader against
every hooks-* module's on-disk path (not a fake coordinator) to assert
each classifies as type='hook', plus an end-to-end real-validation
reproduction of the exact bug for hooks-dedupe specifically. This is the
first loader-level classification test in foundation and guards against
any future hook module picking a name with a colliding keyword.

Fixes: openai_improvement-j3t

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@bkrabach

Copy link
Copy Markdown
Collaborator Author

Root cause

amplifier-core's loader _guess_from_naming() does a first-match substring scan over module-type keywords, and checks "tool" before "hook". The module name hooks-tool-dedupe contains the substring "tool", so it was classified as a Tool module and validated against ToolValidator — which it fails, since it implements the hooks protocol, not the tool protocol.

Fix

Renamed the module hooks-tool-dedupehooks-dedupe. This is the smallest change that resolves the misclassification without touching amplifier-core's loader logic (out of scope for this repo/PR).

Tests added

  • New regression test asserting all hooks-* modules in this repo classify as hooks via _guess_from_naming() (prevents recurrence for any future hook module whose name happens to contain another type's keyword).
  • End-to-end _validate_module() reproduction test covering the renamed module.

Result: 1642 passed (1634 existing + 8 new).

Found via DTU smoke validation (protocol_compliance failure on hooks-tool-dedupe).

Merge note

Self-authored PR; self-approval is not possible for this account, so this is being merged via admin override at explicit user direction, consistent with the same documented precedent applied to foundation #317#325 this week.

@bkrabach
Brian Krabach (bkrabach) merged commit 418bbb3 into main Aug 27, 2026
7 checks passed
Brian Krabach (bkrabach) added a commit that referenced this pull request Aug 28, 2026
…idempotency section (evidence refuted) (#328)

A payload-level investigation found that three recent changes were built on
a measurement artifact: analyses counted the same events 2-4x across
duplicate snapshot copies of session files (proven by identical
tool_call_id + nanosecond timestamps across "duplicates"). Deduped, the
phenomena each change was built to address vanish. This reverts all three
on that evidence.

1. Remove hooks-dedupe module entirely (PR #323 as hooks-tool-dedupe,
   renamed in PR #326). Deleted modules/hooks-dedupe/ and its `hooks:`
   entry in behaviors/agents.yaml. Deduped, the "same-batch duplicate
   reads" it coalesces do not exist: 0 across 314 sessions / 10,320 reads.
   Measured real value was ~19k tokens (~$0.003) across 5 runs against a
   claimed 16.4% savings -- a ~4,800x overstatement.

   Kept: tests/test_hook_module_classification.py (added by #326). It
   guards a real amplifier-core loader fragility (name-based module-type
   guessing misclassifying a `hooks-*` module as `tool`) that is unrelated
   to whether hooks-dedupe itself exists. Removed only the hooks-dedupe
   specific references: the hardcoded
   `assert "hooks-dedupe" in HOOK_MODULE_IDS` and the
   `test_hooks_dedupe_passes_real_validation` end-to-end test (and its
   now-unused `ModuleValidationError` import). The general, dynamically
   parametrized `test_hook_module_classifies_as_hook` test is untouched
   and still covers every remaining `hooks-*` module.

2. Remove PR #320's "BATCH YOUR DELEGATIONS" guidance block from the
   delegate tool's description in
   modules/tool-delegate/amplifier_module_tool_delegate/__init__.py,
   restoring the original "- Launch multiple agents concurrently when
   tasks are independent" line it replaced. A clean same-commit 5v5 A/B
   measured the guidance as inert (treatment waves median 4 vs control
   median 3) -- the earlier apparent win was a version confound -- while
   it cost ~175 tokens on every single request.

   PR #320's two context-file edits (context/agents/multi-agent-patterns.md,
   context/agents/delegation-instructions.md) are untouched; only the
   scope named above is in this revert. PR #327's own deletions in this
   same string (the CRITICAL/ALWAYS/NEVER preamble and the "DEFAULT TO
   DELEGATION" line) are also untouched -- they stay removed.

3. Remove PR #317's "## Idempotency Discipline" section from
   agents/git-ops.md. Its evidence -- "git-ops re-ran identical clone x8 /
   ls-remote x7 under context truncation" -- is refuted: deduped session
   data shows 8 clones of 8 *different* repos, each cloned once, with
   per-repo ls-remote calls. The thrash it was written to prevent never
   happened.

Verified: full suite green (uv run pytest tests/ -q: 1640 passed, 1
skipped -- down from 1642 by exactly the two hooks-dedupe-specific test
instances removed in (1), both accounted for). python_check clean on all
touched files (only pre-existing, unrelated ruff warnings remain in
tool-delegate's __init__.py, none introduced by this change). Repo-wide
grep confirms modules/hooks-dedupe/ is gone with no dangling functional
references (three remaining mentions of hooks-tool-dedupe/hooks-dedupe are
historical narrative inside the kept regression test's docstring/assert
message, explaining why that general test exists).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
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.

2 participants