-
Notifications
You must be signed in to change notification settings - Fork 632
feat(hooks): Add hook decorator #1581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(hooks): Add hook decorator #1581
Conversation
This adds a @hook decorator that transforms Python functions into HookProvider implementations with automatic event type detection from type hints - mirroring the ergonomics of the existing @tool decorator. Features: - Simple decorator syntax: @hook - Automatic event type extraction from type hints - Explicit event type specification: @hook(event=EventType) - Multi-event support: @hook(events=[...]) or Union types - Support for both sync and async hook functions - Preserves function metadata (name, docstring) - Direct invocation for testing New exports: - from strands import hook - from strands.hooks import hook, DecoratedFunctionHook, FunctionHookMetadata, HookMetadata Example: from strands import Agent, hook from strands.hooks import BeforeToolCallEvent @hook def log_tool_calls(event: BeforeToolCallEvent) -> None: print(f'Tool: {event.tool_use}') agent = Agent(hooks=[log_tool_calls]) Fixes strands-agents#1483
- Add cast() for HookCallback type in register_hooks method - Add HookCallback import from registry - Use keyword-only arguments in overload signature 2 to satisfy mypy
This enhancement addresses feedback from @cagataycali - the agent instance is now automatically injected to @hook decorated functions when they have an 'agent' parameter in their signature. Usage: @hook def my_hook(event: BeforeToolCallEvent, agent: Agent) -> None: # agent is automatically injected from event.agent print(f'Agent {agent.name} calling tool') Features: - Detect 'agent' parameter in function signature - Automatically extract agent from event.agent when callback is invoked - Works with both sync and async hooks - Backward compatible - hooks without agent param work unchanged - Direct invocation supports explicit agent override for testing Tests added: - test_agent_param_detection - test_agent_injection_in_repr - test_hook_without_agent_param_not_injected - test_hook_with_agent_param_receives_agent - test_direct_call_with_explicit_agent - test_agent_injection_with_registry - test_async_hook_with_agent_injection - test_hook_metadata_includes_agent_param - test_mixed_hooks_with_and_without_agent
Add 13 new test cases to improve code coverage from 89% to 98%: - TestCoverageGaps: Optional type hint, async/sync agent injection via registry, direct call without agent param, hook() empty parentheses, Union types - TestAdditionalErrorCases: Invalid annotation types, invalid explicit event list - TestEdgeCases: get_type_hints exception fallback, empty type hints fallback Coverage improvements: - Lines 139-141: get_type_hints exception handling - Line 157: Annotation fallback when type hints unavailable - Lines 203-205: NoneType skipping in Optional[X] - Line 216: Invalid annotation error path - Lines 313-320: Async/sync callback with agent injection Addresses codecov patch coverage failure in PR strands-agents#1484.
- Fix mypy type errors by importing HookEvent and properly casting events - Add __get__ descriptor method to support class methods like @tool - Fix agent injection to validate event types at decoration time - Reject agent injection for multiagent events (BaseHookEvent without .agent) - Skip 'self' and 'cls' params when detecting event type for class methods - Remove version check for types.UnionType (SDK requires Python 3.10+) - Add comprehensive tests for new functionality Issues fixed: 1. Mypy type errors accessing event.agent on BaseHookEvent 2. Missing descriptor protocol for class method support 3. Agent injection failing at runtime for multiagent events 4. Merge conflicts with main branch (ModelRetryStrategy, multiagent events)
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Agent injection was unnecessary complexity - users can simply access event.agent directly when needed, which is consistent with how class-based HookProviders work. Changes: - Remove agent parameter detection and injection logic - Remove has_agent_param from HookMetadata - Simplify DecoratedFunctionHook (532 -> 327 lines) - Update tests to remove agent injection tests (53 -> 35 tests) - Add PR_DESCRIPTION.md
| @overload | ||
| def hook(__func: F) -> DecoratedFunctionHook[Any]: ... | ||
|
|
||
|
|
||
| @overload | ||
| def hook( | ||
| *, | ||
| event: type[BaseHookEvent] | None = None, | ||
| events: Sequence[type[BaseHookEvent]] | None = None, | ||
| ) -> Callable[[F], DecoratedFunctionHook[Any]]: ... | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like this decorator idea!
However, I think there are too many ways to use it. From the description I see:
# Type hint detection
@hook
def my_hook(event: BeforeToolCallEvent) -> None: ...
# Explicit event type
@hook(event=BeforeToolCallEvent)
def my_hook(event) -> None: ...
# Multiple events via parameter
@hook(events=[BeforeToolCallEvent, AfterToolCallEvent])
def my_hook(event) -> None: ...
# Multiple events via Union type
@hook
def my_hook(event: BeforeToolCallEvent | AfterToolCallEvent) -> None: ...
# Async hooks
@hook
async def my_hook(event: BeforeToolCallEvent) -> None: ...
# Class methods
class MyHooks:
@hook
def my_hook(self, event: BeforeToolCallEvent) -> None: ...If we look a the Zen of Python we see:
There should be one-- and preferably only one --obvious way to do it.
which I very much agree with.
I suggest we trim it down to passing in the events just with the events parameter like so:
# Explicit event type
@hook(events=[BeforeToolCallEvent])
def my_hook(event) -> None: ...
# Multiple events via parameter
@hook(events=[BeforeToolCallEvent, AfterToolCallEvent])
def my_hook(event) -> None: ...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest we trim it down to passing in the events just with the events parameter like so
I get the point, but I dont like it, because if I am using type hints (for autocomplete, mypy, etc); why do I need to duplicate the events I handle, i.e. write it in both method definition and hook methods?
cagataycali
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review Summary
This is an excellent implementation of the @hook decorator. The design is clean, follows Python best practices, and provides a significantly improved developer experience.
What's Good ✅
-
Elegant API Design: The decorator supports multiple usage patterns seamlessly:
- Simple type hint inference:
@hook+ typed parameter - Explicit single event:
@hook(event=EventType) - Multiple events via list:
@hook(events=[...]) - Union type inference:
event: A | B
- Simple type hint inference:
-
Proper Type Handling: Handles both
typing.Unionand Python 3.10+types.UnionTypeforA | Bsyntax. -
Descriptor Protocol: Correctly implements
__get__for proper method binding in class contexts. -
Comprehensive Tests: 53 test cases covering edge cases including async hooks, class methods, error scenarios, and agent integration.
-
Preserves Function Metadata: Uses
functools.update_wrapperto maintain introspection.
Minor Observations
The implementation handles the "no type hint" error case well - it guides users to use @hook(event=EventType) when type hints aren't available.
Alignment with SDK Tenets
- "Obvious path is happy path" ✅ -
@hookwith type hints is intuitive - "Simple at any scale" ✅ - Works for single functions or complex class-based hooks
- "Accessible to humans and agents" ✅ - Clean API, good docstrings
This reduces boilerplate significantly while maintaining full compatibility with the existing hooks system. Great work @mkmeral!
🤖 AI agent response from the Strands team. Strands Agents. Feedback welcome!
|
|
||
| def test_async_hook_direct_invocation(self): | ||
| """Test async hook direct invocation.""" | ||
| import asyncio |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: static import
|
/strands review |
|
I've asked an agent to bar raise based on our bar raising guidelines. There are good recommendations. I'd actually take some of these as critical right now. I'll get rid of event/events in decorator itself and depend on type hints only for now. If we want it, we can bring it later Agent Verdict: APPROVE with minor suggestions (personally, I'd be a bit more strict) 1. Tenet Alignment Analysis✅ Tenet 1: Simple at any scaleRating: STRONG ALIGNMENT The decorator provides a dramatically simpler API for the common case: # Before: 8 lines minimum
class LoggingHooks(HookProvider):
def register_hooks(self, registry: HookRegistry) -> None:
registry.add_callback(BeforeToolCallEvent, self.on_tool_call)
def on_tool_call(self, event: BeforeToolCallEvent) -> None:
print(f"Tool: {event.tool_use}")
# After: 3 lines
@hook
def log_tool_calls(event: BeforeToolCallEvent) -> None:
print(f"Tool: {event.tool_use}")The same pattern scales to production with multi-event handlers, async support, and class method integration. ✅ Tenet 2: Extensible by designRating: STRONG ALIGNMENT Multiple extension points are preserved:
✅ Tenet 3: ComposabilityRating: STRONG ALIGNMENT Decorated hooks work seamlessly with:
✅ Tenet 4: The obvious path is the happy pathRating: STRONG ALIGNMENT The simplest usage is also the recommended one: @hook
def my_hook(event: BeforeToolCallEvent) -> None:
...Type hints guide developers toward type-safe code. Error messages are clear when type detection fails:
✅ Tenet 5: Accessible to humans and agentsRating: STRONG ALIGNMENT
✅ Tenet 6: Embrace common standardsRating: STRONG ALIGNMENT
2. Decision Record Compliance✅ "Hooks as Low-Level Primitives, Not High-Level Abstractions" (Jan 6, 2026)COMPLIANT - The ✅ "Prefer Flat Namespaces Over Nested Modules" (Jan 16, 2026)COMPLIANT - from strands import hook # ✓ Flat namespace✅ "When Internal Interfaces Should Extend HookProvider" (Jan 21, 2026)COMPLIANT - ✅ "Provide Both Low-Level and High-Level APIs" (Jan 30, 2026)COMPLIANT - This PR adds the high-level decorator API while preserving the low-level 3. API Design Analysis3.1 Naming Assessment
Minor concern: 3.2 Signature Assessmentdef hook(
func: F | None = None,
event: type[BaseHookEvent] | None = None,
events: Sequence[type[BaseHookEvent]] | None = None,
) -> DecoratedFunctionHook[Any] | Callable[[F], DecoratedFunctionHook[Any]]Assessment:
Verification: Looking at the implementation, if events is not None:
event_types = list(events)
elif event is not None:
event_types = [event]This is acceptable but could benefit from a validation error if both are specified. 3.3 Default Behavior Assessment
3.4 Review Comment Analysis: "Too Many Ways"The reviewer raised a concern about too many ways to use the decorator. Let me analyze:
My assessment: The API is appropriately designed. The "many ways" are actually:
The author's response is correct: forcing 4. Use Case CoverageAddressed Use Cases ✅
Potential Missing Use Cases
|
| Use Case | Status | Impact |
|---|---|---|
Context injection (like @tool(context=True)) |
Not implemented | Low - can access agent via event.agent |
| Hook ordering/priority | Not implemented | Low - registration order is sufficient |
| Hook from directory loading | Not implemented | Low - can be added later |
Assessment: Missing features align with the issue's "Questions" section and can be added in future PRs without breaking changes.
5. Public API Surface Review
New Exports from strands:
from strands import hook # ✅ AppropriateNew Exports from strands.hooks:
from strands.hooks import (
hook, # ✅ Also available here for discoverability
DecoratedFunctionHook, # ✅ Needed for isinstance checks
FunctionHookMetadata, # ⚠️ Consider: is this needed publicly?
HookMetadata, # ⚠️ Consider: is this needed publicly?
)Concern: Are FunctionHookMetadata and HookMetadata intended for public use?
Looking at the code:
FunctionHookMetadatais only used internally in the decoratorHookMetadatais a simple dataclass with hook info
Recommendation: Consider whether FunctionHookMetadata should be public. If it's only for internal use, prefix with _ or remove from __all__. HookMetadata may have legitimate public use for introspection.
6. Potential Issues
6.1 Both event and events specified (Low severity)
@hook(event=BeforeToolCallEvent, events=[AfterToolCallEvent])
def my_hook(event): ... # events takes precedence silentlySuggestion: Add validation to raise ValueError if both are specified.
6.2 Type variable naming (Cosmetic)
TEvent = TypeVar("TEvent", bound=BaseHookEvent) # In decorator.py
TEvent = TypeVar("TEvent", bound=BaseHookEvent) # In registry.pyThese are separate type variables in different modules. This is fine for type checking but could cause confusion. Consider THookEvent in the decorator module for clarity.
6.3 Return type DecoratedFunctionHook[Any] (Type safety concern)
The decorator returns DecoratedFunctionHook[Any] which loses the specific event type. This is a limitation of Python's type system for decorators that modify return types based on runtime analysis. The @tool decorator has the same limitation.
7. Positive Highlights
- Consistent with
@toolpatterns - Users familiar with@toolwill immediately understand@hook - Comprehensive test coverage - 35 test cases covering normal usage, errors, edge cases
- Clean implementation - Uses Python standard library idiomatically
- Good error messages - Clear guidance when type detection fails
- Docstrings - Well-documented public API
- Backward compatible - Existing
HookProvidercode continues to work
8. Recommendations
Must Address (None)
No blocking issues.
Should Consider
-
Add validation for mutually exclusive parameters:
if event is not None and events is not None: raise ValueError("Cannot specify both 'event' and 'events'. Use 'events' for multiple event types.")
-
Reconsider public exports:
FunctionHookMetadatamay not need to be public- If kept public, add docstring explaining use case
Nice to Have
- Update
docs/HOOKS.mdto include decorator-based examples - Consider adding
HookMetadata.is_boundto indicate if hook is bound to an instance
9. Verdict
APPROVE ✅
This PR introduces a well-designed, tenet-aligned API that significantly improves developer ergonomics for defining hooks. The implementation is clean, well-tested, and follows established SDK patterns.
The concerns raised by the reviewer about "too many ways" are unfounded - the API provides necessary flexibility while maintaining a clear "happy path" via type hint detection. The patterns are consistent with the existing @tool decorator.
Summary Checklist
- Aligns with SDK tenets
- Complies with decision records
- Consistent with existing patterns (
@tool) - Well-tested (35 test cases, >96% coverage)
- Clear error messages
- Backward compatible
- Appropriate public API surface
- Minor: Could validate mutually exclusive params
- Minor: Consider public export necessity
Appendix: Quick Reference
Basic Usage
from strands import Agent, hook
from strands.hooks import BeforeToolCallEvent
@hook
def log_tools(event: BeforeToolCallEvent) -> None:
print(f"Tool: {event.tool_use}")
agent = Agent(hooks=[log_tools])Multi-Event Hook
@hook
def audit(event: BeforeToolCallEvent | AfterToolCallEvent) -> None:
print(f"Event: {type(event).__name__}")Async Hook
@hook
async def async_logger(event: BeforeToolCallEvent) -> None:
await some_async_operation()
Description
This PR adds a
@hookdecorator that transforms Python functions intoHookProviderimplementations with automatic event type detection from type hints.Motivation
Defining hooks currently requires implementing the
HookProviderprotocol with a class, which is verbose for simple use cases:The
@hookdecorator provides a simpler function-based approach that reduces boilerplate while maintaining full compatibility with the existing hooks system.Resolves: #1483
Public API Changes
New
@hookdecorator exported fromstrandsandstrands.hooks:The decorator supports multiple usage patterns:
Related Issues
Fixes #1483
Documentation PR
No documentation changes required.
Type of Change
New feature
Testing
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.