Skip to content

Conversation

@mkmeral
Copy link
Contributor

@mkmeral mkmeral commented Jan 28, 2026

Description

This PR adds a @hook decorator that transforms Python functions into HookProvider implementations with automatic event type detection from type hints.

Motivation

Defining hooks currently requires implementing the HookProvider protocol with a class, which is verbose for simple use cases:

# Current approach - verbose
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}")

agent = Agent(hooks=[LoggingHooks()])

The @hook decorator provides a simpler function-based approach that reduces boilerplate while maintaining full compatibility with the existing hooks system.

Resolves: #1483

Public API Changes

New @hook decorator exported from strands and strands.hooks:

# After - concise
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])

The decorator supports multiple usage patterns:

# 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: ...

Related Issues

Fixes #1483

Documentation PR

No documentation changes required.

Type of Change

New feature

Testing

  • Added comprehensive unit tests (53 test cases)
  • Tests cover: basic usage, explicit events, multi-events, union types, async, class methods, agent injection, error handling
  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

strands-agent and others added 7 commits January 28, 2026 19:46
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
Copy link

codecov bot commented Jan 28, 2026

Codecov Report

❌ Patch coverage is 96.69421% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/strands/hooks/decorator.py 96.63% 2 Missing and 2 partials ⚠️

📢 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
@github-actions github-actions bot added size/xl and removed size/xl labels Jan 28, 2026
@github-actions github-actions bot added size/xl and removed size/xl labels Jan 28, 2026
@github-actions github-actions bot added size/l and removed size/xl labels Jan 28, 2026
@mkmeral mkmeral marked this pull request as ready for review January 29, 2026 03:55
@mkmeral mkmeral enabled auto-merge (squash) January 29, 2026 03:55
Comment on lines +261 to +271
@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]]: ...

Copy link
Contributor

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: ...

Copy link
Contributor Author

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?

Copy link
Member

@cagataycali cagataycali left a 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 ✅

  1. 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
  2. Proper Type Handling: Handles both typing.Union and Python 3.10+ types.UnionType for A | B syntax.

  3. Descriptor Protocol: Correctly implements __get__ for proper method binding in class contexts.

  4. Comprehensive Tests: 53 test cases covering edge cases including async hooks, class methods, error scenarios, and agent integration.

  5. Preserves Function Metadata: Uses functools.update_wrapper to 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" ✅ - @hook with 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
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: static import

@mehtarac
Copy link
Member

mehtarac commented Feb 3, 2026

/strands review

@mkmeral
Copy link
Contributor Author

mkmeral commented Feb 3, 2026

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 scale

Rating: 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 design

Rating: STRONG ALIGNMENT

Multiple extension points are preserved:

  • Type hint detection (automatic)
  • Explicit event= parameter (fallback)
  • Multiple events via events= list
  • Union type hints for multi-event
  • Async/sync flexibility
  • Class method compatibility

✅ Tenet 3: Composability

Rating: STRONG ALIGNMENT

Decorated hooks work seamlessly with:

  • Existing HookProvider classes (mixable in Agent(hooks=[...]))
  • HookRegistry (via register_hooks())
  • All existing hook events (single-agent and multi-agent)
  • Direct invocation for testing

✅ Tenet 4: The obvious path is the happy path

Rating: 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:

  • "must have at least one parameter for the event"
  • "must have a type hint for the event parameter, or use @hook(event=EventType)"

✅ Tenet 5: Accessible to humans and agents

Rating: STRONG ALIGNMENT

  • Comprehensive docstrings with examples
  • Consistent with @tool decorator patterns
  • Type annotations throughout
  • Clear naming (hook, DecoratedFunctionHook, HookMetadata)

✅ Tenet 6: Embrace common standards

Rating: STRONG ALIGNMENT

  • Follows Python decorator patterns (functools.wraps, descriptor protocol)
  • Uses standard typing constructs (Union, get_type_hints, get_origin)
  • Mirrors the established @tool decorator design
2. Decision Record Compliance

✅ "Hooks as Low-Level Primitives, Not High-Level Abstractions" (Jan 6, 2026)

COMPLIANT - The @hook decorator is a high-level convenience built on top of the low-level HookProvider protocol. Both remain available.

✅ "Prefer Flat Namespaces Over Nested Modules" (Jan 16, 2026)

COMPLIANT - hook is exported from the top-level strands namespace:

from strands import hook  # ✓ Flat namespace

✅ "When Internal Interfaces Should Extend HookProvider" (Jan 21, 2026)

COMPLIANT - DecoratedFunctionHook implements HookProvider protocol correctly.

✅ "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 HookProvider class-based approach.

3. API Design Analysis

3.1 Naming Assessment

Element Name Assessment
Decorator hook ✅ Consistent with tool, clear purpose
Wrapper class DecoratedFunctionHook ✅ Descriptive, follows DecoratedFunctionTool pattern
Metadata class FunctionHookMetadata ✅ Follows FunctionToolMetadata pattern
Helper dataclass HookMetadata ⚠️ See concern below

Minor concern: HookMetadata name is generic and doesn't indicate it's specific to decorated function hooks. Consider DecoratedHookMetadata for clarity, though this is a minor point since it's primarily for internal use.

3.2 Signature Assessment

def hook(
    func: F | None = None,
    event: type[BaseHookEvent] | None = None,
    events: Sequence[type[BaseHookEvent]] | None = None,
) -> DecoratedFunctionHook[Any] | Callable[[F], DecoratedFunctionHook[Any]]

Assessment:

  • ✅ Supports both @hook and @hook() syntax (consistent with @tool)
  • event for single event type (singular, clear)
  • events for multiple event types (plural, clear)
  • ⚠️ Both event and events can be specified - should one take precedence or error?

Verification: Looking at the implementation, events takes precedence over event:

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

Scenario Default Assessment
No type hint, no explicit event Error ✅ Correct - forces explicit declaration
Invalid event type Error ✅ Correct - validates against BaseHookEvent
Async function Auto-detected ✅ Correct - uses inspect.iscoroutinefunction
Optional[EventType] Extracts EventType ✅ Correct - handles None in union

3.4 Review Comment Analysis: "Too Many Ways"

The reviewer raised a concern about too many ways to use the decorator. Let me analyze:

Pattern Necessity Rationale
Type hint detection Essential Primary pattern, enables type safety
event= parameter Necessary Fallback when type hints unavailable
events= parameter Necessary Multi-event handlers
Union type hint Natural Just type hint detection for unions
Async hooks Feature Not a separate pattern, Python native
Class methods Natural Python descriptor protocol, not API design

My assessment: The API is appropriately designed. The "many ways" are actually:

  1. Two primary patterns (type hint vs explicit), which is the same as @tool
  2. Two variants for multi-event (explicit list vs union), which is Python-native
  3. Async/class methods are orthogonal features, not API patterns

The author's response is correct: forcing events= even when type hints are available would violate DRY and ignore Python's type system.

4. Use Case Coverage

Addressed Use Cases ✅

Use Case Supported Example
Simple single-event hook @hook def f(e: BeforeToolCallEvent): ...
Multi-event hook @hook(events=[...]) or union type
Async hook @hook async def f(e): ...
Hook in class Via descriptor protocol
Direct invocation (testing) my_hook(mock_event)
Package distribution Decorated functions are importable
Mixed with class-based hooks Both implement HookProvider

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  # ✅ Appropriate

New 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:

  • FunctionHookMetadata is only used internally in the decorator
  • HookMetadata is 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 silently

Suggestion: 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.py

These 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
  1. Consistent with @tool patterns - Users familiar with @tool will immediately understand @hook
  2. Comprehensive test coverage - 35 test cases covering normal usage, errors, edge cases
  3. Clean implementation - Uses Python standard library idiomatically
  4. Good error messages - Clear guidance when type detection fails
  5. Docstrings - Well-documented public API
  6. Backward compatible - Existing HookProvider code continues to work
8. Recommendations

Must Address (None)

No blocking issues.

Should Consider

  1. 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.")
  2. Reconsider public exports:

    • FunctionHookMetadata may not need to be public
    • If kept public, add docstring explaining use case

Nice to Have

  1. Update docs/HOOKS.md to include decorator-based examples
  2. Consider adding HookMetadata.is_bound to 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()

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.

[Feature] @hook decorator for simplified hook definitions

6 participants