fix(hooks): hooks-tool-dedupe fails protocol_compliance via top-level hooks block - #326
Conversation
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>
Root cause
FixRenamed the module Tests added
Result: 1642 passed (1634 existing + 8 new). Found via DTU smoke validation (protocol_compliance failure on Merge noteSelf-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. |
…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>
Root cause
Confirmed at
amplifier-core1.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-dedupecontains both"tool"and"hook"as substrings. Because"tool"is checked before"hook"in that dict, the module was classified astype="tool"and validated byToolValidatorinstead ofHookValidator.ToolValidator._check_protocol_compliance(validation/tool.py:277-284) requiresmount()to either populatecoordinator.mount_points["tools"]or return an object with.name/.description/.execute.hooks-tool-dedupe.mount()correctly does neither (it registers atool:posthook handler and returns a plain descriptor dict, exactly like its 5 working siblings) — so validation fails with exactly the reported error:Empirical proof (against amplifier-core 1.6.1's real loader):
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-levelhooks:block andbehaviors/agents.yaml'shooks:block): both resolve the module id through the identicalloader.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-deduperather 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/amplifier_module_hooks_tool_dedupe->amplifier_module_hooks_dedupepyproject.toml: package name, entry-point name (hooks-tool-dedupe->hooks-dedupe), wheel package listuv.lock: self-referential package nameToolDedupeHookclass ->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"fieldbehaviors/agents.yaml: module id and git sourcesubdirectory=pathNot 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 everyhooks-*module directory (discovered dynamically), calls amplifier-core's realModuleLoader._get_module_metadata()against each module's actual on-disk path and assertstype == "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 realModuleLoader._validate_module()(the exact call site insideload()that raisedModuleValidationError) againsthooks-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 callmount()directly against a fake coordinator and would not have caught this class of bug, since the bug is in classification/validator-selection beforemount()is ever reached).Smoke-test evidence (the original failure, reproduced pre-fix)
Test results
tests/test_hook_module_classification.py(new, 8 tests): 8 passedmodules/hooks-dedupe/tests/(existing 25 tests, updated for rename): 25 passeduv run pytest tests/ -q --tb=short, matches CI's.github/workflows/ci.ymlinvocation): 1642 passed, 1 skipped (1634 pre-existing + 8 new)python_check(ruff format/lint, pyright, stub-check) on all changed files: cleanCore 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