Skip to content

feat(hooks): accept callable hook callbacks in Agent constructor #1992

Open
agent-of-mkmeral wants to merge 4 commits intostrands-agents:mainfrom
agent-of-mkmeral:feat/hooks-accept-callables
Open

feat(hooks): accept callable hook callbacks in Agent constructor #1992
agent-of-mkmeral wants to merge 4 commits intostrands-agents:mainfrom
agent-of-mkmeral:feat/hooks-accept-callables

Conversation

@agent-of-mkmeral
Copy link
Copy Markdown
Contributor

Motivation

Currently, the hooks parameter in Agent.__init__ only accepts HookProvider instances:

# This works
agent = Agent(hooks=[MyHookProvider()])

# This does NOT work (TypeError)
def on_start(event: BeforeInvocationEvent) -> None:
    print('Starting!')
agent = Agent(hooks=[on_start])  # ❌

However, Agent.add_hook() (added in #1706) already supports plain callables:

# This works fine after construction
agent = Agent()
agent.add_hook(on_start)  # ✅

This inconsistency is confusing. If you can register a callable via add_hook(), you should also be able to pass it via the constructor.

Changes

src/strands/agent/agent.py:

  • Updated hooks param type from list[HookProvider] to list[HookProvider | HookCallback]
  • Updated init logic to dispatch: HookProvideradd_hook(), callable → add_callback(None, hook)
  • Added ValueError for invalid hook types with clear error message
  • Updated docstring

After This Change

def on_start(event: BeforeInvocationEvent) -> None:
    print('Starting!')

def on_model(event: BeforeModelCallEvent) -> None:
    print('Model call!')

# All of these now work:
agent = Agent(hooks=[on_start])  # ✅ Plain callable
agent = Agent(hooks=[MyHookProvider()])  # ✅ HookProvider (unchanged)
agent = Agent(hooks=[on_start, MyHookProvider(), on_model])  # ✅ Mixed list

Callables must have typed event parameters (same requirement as agent.add_hook()). Lambdas without type hints will raise ValueError with a helpful message.

Tests

Added 12 new test cases in tests/strands/agent/test_agent_hooks_callable.py:

  • Plain callable acceptance
  • HookProvider backward compatibility
  • Mixed list (HookProvider + callable)
  • Callable invoked during agent lifecycle
  • Invalid hook type raises ValueError
  • None and empty list handling
  • Multiple callables
  • Async callable support
  • Async AgentInitializedEvent rejection
  • Lambda without type hint raises ValueError

All 24 existing hook tests + 61 hook registry tests continue to pass.

Related

cc @mkmeral

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Mar 27, 2026

/strands review

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Mar 27, 2026

@agent-of-mkmeral did you not run hatch run prepare to check everything? do it, fix the issues

@github-actions
Copy link
Copy Markdown

Assessment: ✅ Approve

This PR addresses a legitimate usability gap by enabling the hooks constructor parameter to accept plain callable callbacks, matching the behavior of agent.add_hook(). The implementation is clean and the test coverage is comprehensive.

Review Summary
  • API Design: Minimal change that adds consistency with existing add_hook() method - aligns well with SDK tenets (simple at any scale, obvious path is happy path)
  • Implementation: Correctly dispatches between HookProvider and callable types, with clear error message for invalid hooks
  • Testing: 12 well-structured test cases covering plain callables, HookProviders, mixed lists, async callbacks, and error conditions
  • Backward Compatibility: Fully maintained - existing HookProvider usage continues to work unchanged

Well done on the clear PR description with before/after examples!

@agent-of-mkmeral
Copy link
Copy Markdown
Contributor Author

Fixed! Ran hatch run prepare and addressed the issues:

Commit: e3f9481

Issues found & fixed

Issue Fix
F841 unused variable agent (line 48) Removed assignment — Agent(hooks=[provider], ...)
F841 unused variable agent (line 150) Removed assignment — Agent(hooks=[callback_a, callback_b], ...)
Unused import MagicMock Removed by formatter
Formatting inconsistencies Auto-fixed by hatch run format

Verification

  • hatch run format — clean
  • hatch run lintAll checks passed!
  • ruff check — clean
  • mypy ./srcno issues found in 140 source files
  • ✅ All 121 collected tests passed (the Python 3.14 xray propagator errors are a pre-existing upstream issue)

@mkmeral mkmeral changed the title feat(hooks): accept callable hook callbacks in Agent constructor hooks parameter feat(hooks): accept callable hook callbacks in Agent constructor Mar 27, 2026
mkmeral
mkmeral previously approved these changes Mar 27, 2026
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this be added to the existing agent test file?

Also this is a lot of testing code for a simple addition. Can we trim this down?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done! Deleted test_agent_hooks_callable.py entirely and moved 5 trimmed-down essential tests into the existing test_agent_hooks.py:

  • test_hooks_param_accepts_callable — plain callable via hooks param
  • test_hooks_param_accepts_mixed_list — HookProvider + callable mix
  • test_hooks_param_accepts_tuple — Sequence support (new)
  • test_hooks_param_invalid_hook_raises_error — error path
  • test_hooks_param_callable_invoked_during_lifecycle — fires during agent call

Removed the other 7 tests that were redundant (None/empty list already work by default, async/lambda/multiple were over-testing).

12 tests → 5 tests. All 29 tests in the file pass ✅

assert events_received[0].agent is agent

def test_hooks_param_accepts_hook_provider(self):
"""Verify that HookProvider still works as before (backward compatibility)."""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is duplicated, lets remove it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed! This was a duplicate of test_agent__init__hooks which already verifies HookProvider works via the hooks= param. The whole file is deleted now — tests consolidated into test_agent_hooks.py.

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Mar 31, 2026

@agent-of-mkmeral check the latest review, update as needed

@agent-of-mkmeral
Copy link
Copy Markdown
Contributor Author

Addressed all 3 review threads from @Unshure!

Commit: 168d76a

Changes

Thread Feedback Fix
1 tuple support nit ✅ Changed list[...]Sequence[...] — accepts list, tuple, any sequence
2 Move tests to existing file, trim down ✅ Deleted test_agent_hooks_callable.py, moved 5 essential tests to test_agent_hooks.py (12 → 5)
3 Duplicate test ✅ Removed — already covered by test_agent__init__hooks

Verification

  • hatch run format — 351 files unchanged
  • hatch run lint — All checks passed!
  • ✅ All 29 hook tests pass (24 existing + 5 new)
  • ✅ Net -120 lines of test code

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Apr 1, 2026

/strands review

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 1, 2026

Assessment: ✅ Approve

Re-reviewed after updates. All reviewer feedback has been addressed appropriately.

Changes Since Last Review
Feedback Resolution
Move tests to existing file ✅ Consolidated into test_agent_hooks.py
Trim down test count ✅ Reduced from 12 to 4 essential tests
Remove duplicate test ✅ Removed
Sequence vs list ✅ Correctly reverted to list per maintainer guidance
Final State
  • Implementation: Clean type dispatch between HookProvider and callable
  • Tests: 4 focused tests covering callable, mixed list, error case, and lifecycle
  • Backward Compatibility: Maintained

Ready to merge.

agent-of-mkmeral and others added 4 commits April 1, 2026 21:27
…s parameter

Previously, the hooks parameter in Agent.__init__ only accepted
HookProvider instances. This change allows passing plain callable
hook callbacks (functions with typed event parameters) directly,
matching the flexibility of Agent.add_hook().

The hooks param now accepts a mixed list of HookProvider instances
and/or callable hook callbacks:

    def on_start(event: BeforeInvocationEvent) -> None:
        print('Starting!')

    agent = Agent(hooks=[on_start, MyHookProvider()])

This provides a more convenient API for simple hook use cases where
creating a full HookProvider class is unnecessary.

Changes:
- Updated hooks param type: list[HookProvider | HookCallback] | None
- Added isinstance check to dispatch HookProviders vs callables
- Added ValueError for invalid hook types
- Added comprehensive tests (12 test cases)
- Remove unused variable assignments (F841 lint errors)
- Apply auto-formatting from hatch run format
Review feedback from @Unshure:

1. hooks param now accepts Sequence (list, tuple) instead of just list
   - Changed type hint to Sequence[HookProvider | HookCallback]
   - Added Sequence import from collections.abc

2. Moved tests to existing test_agent_hooks.py and trimmed down
   - Removed separate test_agent_hooks_callable.py file
   - Kept 5 essential tests: callable, mixed list, tuple, invalid, lifecycle
   - Removed duplicate HookProvider test (already covered by test_agent__init__hooks)
   - Removed redundant tests (None, empty list, async, lambda, multiple)

3. Removed duplicated test_hooks_param_accepts_hook_provider
   - This was already covered by existing test_agent__init__hooks
Per @mkmeral's feedback: keep hooks as list[...] for devx simplicity.
If users want explicit tuple/Sequence control, they can use add_hook().

Changes:
- Revert Sequence -> list in hooks type hint
- Revert docstring to not mention tuple support
- Remove Sequence import (no longer needed)
- Remove test_hooks_param_accepts_tuple test
@agent-of-mkmeral
Copy link
Copy Markdown
Contributor Author

Rebased onto latest main to fix the check-api CI failure.

The check-api (griffe) failure was caused by upstream API changes in main (e.g. Agent.as_tool removal, Model.stateful removal) — none related to this PR's changes.

Post-rebase verification

  • griffe check — clean (0 warnings)
  • ✅ All 28 hook tests pass (24 existing + 4 new)
  • ✅ No merge conflicts

CI should go green now. Ready for merge when you're happy with it @mkmeral.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants