flowra 0.0.2.dev5__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. {flowra-0.0.2.dev5 → flowra-0.0.3}/CHANGELOG.md +11 -0
  2. {flowra-0.0.2.dev5 → flowra-0.0.3}/PKG-INFO +1 -1
  3. {flowra-0.0.2.dev5 → flowra-0.0.3}/context7.json +6 -6
  4. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/architecture.md +2 -1
  5. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/lib.md +85 -131
  6. flowra-0.0.3/docs/research/hooks_redesign.md +273 -0
  7. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/research/strands_comparison.md +4 -4
  8. flowra-0.0.3/docs/research/voice_stt.md +105 -0
  9. flowra-0.0.3/docs/todo.md +126 -0
  10. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/console_chat.py +9 -14
  11. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/llm_logging.py +5 -4
  12. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tui_chat.py +48 -44
  13. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/__init__.py +5 -0
  14. flowra-0.0.3/flowra/agent/hooks.py +113 -0
  15. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/__init__.py +2 -4
  16. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/agent.py +8 -11
  17. flowra-0.0.3/flowra/lib/chat/hook_events.py +23 -0
  18. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/spec.py +1 -1
  19. flowra-0.0.3/flowra/lib/tool_loop/__init__.py +71 -0
  20. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/agent.py +50 -56
  21. flowra-0.0.3/flowra/lib/tool_loop/hook_events.py +178 -0
  22. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/spec.py +1 -1
  23. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/version.py +1 -1
  24. flowra-0.0.3/tests/agent/test_hooks.py +167 -0
  25. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_chat_agent.py +45 -34
  26. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_tool_loop_agent.py +127 -125
  27. flowra-0.0.2.dev5/docs/todo.md +0 -22
  28. flowra-0.0.2.dev5/flowra/lib/chat/hook_executor.py +0 -21
  29. flowra-0.0.2.dev5/flowra/lib/chat/hooks.py +0 -43
  30. flowra-0.0.2.dev5/flowra/lib/tool_loop/__init__.py +0 -107
  31. flowra-0.0.2.dev5/flowra/lib/tool_loop/hook_executor.py +0 -221
  32. flowra-0.0.2.dev5/flowra/lib/tool_loop/hooks.py +0 -479
  33. {flowra-0.0.2.dev5 → flowra-0.0.3}/.claude/commands/update-pricing.md +0 -0
  34. {flowra-0.0.2.dev5 → flowra-0.0.3}/.env.example +0 -0
  35. {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/master.yml +0 -0
  36. {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/publish.yml +0 -0
  37. {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/pull_request.yml +0 -0
  38. {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/pull_request_e2e.yml +0 -0
  39. {flowra-0.0.2.dev5 → flowra-0.0.3}/.gitignore +0 -0
  40. {flowra-0.0.2.dev5 → flowra-0.0.3}/.python-version +0 -0
  41. {flowra-0.0.2.dev5 → flowra-0.0.3}/CLAUDE.md +0 -0
  42. {flowra-0.0.2.dev5 → flowra-0.0.3}/LICENSE +0 -0
  43. {flowra-0.0.2.dev5 → flowra-0.0.3}/Makefile +0 -0
  44. {flowra-0.0.2.dev5 → flowra-0.0.3}/README.md +0 -0
  45. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/agent.md +0 -0
  46. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/llm.md +0 -0
  47. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_plan.md +0 -0
  48. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step1_structure.md +0 -0
  49. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step2_code_style.md +0 -0
  50. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step3_documentation.md +0 -0
  51. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step4_doc_readability.md +0 -0
  52. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step5_doc_audit.md +0 -0
  53. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step6_tests.md +0 -0
  54. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/runtime.md +0 -0
  55. {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/tools.md +0 -0
  56. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/__init__.py +0 -0
  57. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/app_agent.py +0 -0
  58. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/llm_routing.py +0 -0
  59. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/menu_agent.py +0 -0
  60. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/menu_agent_class.py +0 -0
  61. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/model_registry.py +0 -0
  62. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/system_prompt.txt +0 -0
  63. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/__init__.py +0 -0
  64. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/calculator.py +0 -0
  65. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/random_numbers.py +0 -0
  66. {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/switch_model.py +0 -0
  67. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/__init__.py +0 -0
  68. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent.py +0 -0
  69. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_def.py +0 -0
  70. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_registry.py +0 -0
  71. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_store.py +0 -0
  72. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/compile.py +0 -0
  73. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/interrupt_helpers.py +0 -0
  74. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/interrupt_token.py +0 -0
  75. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/service_locator.py +0 -0
  76. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/step_decorator.py +0 -0
  77. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/stored_values.py +0 -0
  78. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/__init__.py +0 -0
  79. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/config.py +0 -0
  80. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/config_value.py +0 -0
  81. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/llm_config.py +0 -0
  82. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/_tool_call_agent.py +0 -0
  83. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/cache.py +0 -0
  84. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/config.py +0 -0
  85. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/context.py +0 -0
  86. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/__init__.py +0 -0
  87. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/_base.py +0 -0
  88. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/blocks.py +0 -0
  89. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/messages.py +0 -0
  90. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/__init__.py +0 -0
  91. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/anthropic.py +0 -0
  92. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/google.py +0 -0
  93. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/openai.py +0 -0
  94. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/provider.py +0 -0
  95. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/__init__.py +0 -0
  96. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/anthropic_vertex.py +0 -0
  97. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/google_vertex.py +0 -0
  98. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/openai.py +0 -0
  99. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/request.py +0 -0
  100. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/response.py +0 -0
  101. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/schema_formatting.py +0 -0
  102. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/schema_validation.py +0 -0
  103. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/stream.py +0 -0
  104. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/tools.py +0 -0
  105. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/py.typed +0 -0
  106. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/__init__.py +0 -0
  107. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/_sealed_scope.py +0 -0
  108. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/engine.py +0 -0
  109. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/execution.py +0 -0
  110. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/interrupt.py +0 -0
  111. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/runtime.py +0 -0
  112. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/runtime_scope.py +0 -0
  113. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/serialization.py +0 -0
  114. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/__init__.py +0 -0
  115. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/file.py +0 -0
  116. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/in_memory.py +0 -0
  117. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/session_storage.py +0 -0
  118. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/__init__.py +0 -0
  119. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/local_tool.py +0 -0
  120. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/mcp_connection.py +0 -0
  121. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/tool_group.py +0 -0
  122. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/tool_registry.py +0 -0
  123. {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/types.py +0 -0
  124. {flowra-0.0.2.dev5 → flowra-0.0.3}/pyproject.toml +0 -0
  125. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/__init__.py +0 -0
  126. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/__init__.py +0 -0
  127. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent.py +0 -0
  128. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent_def.py +0 -0
  129. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent_registry.py +0 -0
  130. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_compile.py +0 -0
  131. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_step_ref.py +0 -0
  132. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_values.py +0 -0
  133. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_with_interrupt.py +0 -0
  134. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/__init__.py +0 -0
  135. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_config_value.py +0 -0
  136. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_tool_call_agent.py +0 -0
  137. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/tool_loop/__init__.py +0 -0
  138. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/tool_loop/test_cache.py +0 -0
  139. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/__init__.py +0 -0
  140. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/__init__.py +0 -0
  141. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_anthropic.py +0 -0
  142. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_google.py +0 -0
  143. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_openai.py +0 -0
  144. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/__init__.py +0 -0
  145. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_anthropic_e2e.py +0 -0
  146. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_anthropic_vertex.py +0 -0
  147. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_google_vertex.py +0 -0
  148. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_google_vertex_e2e.py +0 -0
  149. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_openai_e2e.py +0 -0
  150. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_openai_provider.py +0 -0
  151. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_metadata.py +0 -0
  152. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_response.py +0 -0
  153. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_schema_formatting.py +0 -0
  154. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_schema_validation.py +0 -0
  155. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_stream.py +0 -0
  156. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/__init__.py +0 -0
  157. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/__init__.py +0 -0
  158. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/test_file.py +0 -0
  159. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/test_in_memory.py +0 -0
  160. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_engine.py +0 -0
  161. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_interrupt.py +0 -0
  162. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_persistence.py +0 -0
  163. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_runtime.py +0 -0
  164. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_scope.py +0 -0
  165. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_serialization.py +0 -0
  166. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/__init__.py +0 -0
  167. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_local_tool.py +0 -0
  168. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_mcp_connection.py +0 -0
  169. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_tool_group.py +0 -0
  170. {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_tool_registry.py +0 -0
  171. {flowra-0.0.2.dev5 → flowra-0.0.3}/uv.lock +0 -0
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.3] - 2026-03-08
11
+
12
+ ### Changed
13
+ - **Hook system redesign**: replaced `ToolLoopHooks`/`ChatHooks` (24 Protocol classes, 12 executor
14
+ functions, 5 result dataclasses) with generic `HookRegistry` + `Event[T]` pattern. Supports
15
+ multiple handlers per event, `HookProvider` for distributable hook sets, and `propagate_to()`
16
+ for parent/child isolation. Hook events are plain mutable dataclasses with result fields.
17
+ - `ToolLoopSpec.meta` / `ChatSpec.meta` renamed to `metadata`.
18
+
19
+ ## [0.0.2] - 2026-03-08
20
+
10
21
  ### Added
11
22
  - **Streaming**: `LLMProvider.stream()` method returns `AsyncIterator[StreamEvent]` with `TextDelta`, `ThinkingDelta`, and `ContentComplete` events. All three built-in providers implement real-time streaming. Default fallback calls `call()` and yields `ContentComplete`.
12
23
  - **Anthropic thinking**: `AnthropicVertexAdditionalConfig` with `thinking_budget_tokens` enables extended thinking on Claude models. Thinking blocks are now parsed from Anthropic responses (`ThinkingBlock`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowra
3
- Version: 0.0.2.dev5
3
+ Version: 0.0.3
4
4
  Summary: Flowra — flow infrastructure for building stateful LLM agents
5
5
  Project-URL: Repository, https://github.com/anna-money/flowra
6
6
  Project-URL: Changelog, https://github.com/anna-money/flowra/blob/master/CHANGELOG.md
@@ -62,15 +62,15 @@
62
62
  "ChatAgent usage: runtime.run(agent=ChatAgent, step=ChatAgent.process_message, spec=ChatSpec(user_message=text))",
63
63
  "ChatSpec(user_message: str) is the input, ChatResult(response: str | None, usage: Usage | None) is the output",
64
64
  "ChatConfig configures ChatAgent: ChatConfig(llm_config=LLMConfig(model='...'), system_messages=[SystemMessage(...)])",
65
- "ChatHooks provides chat-level lifecycle hooks (DI service, same pattern as ToolLoopHooks): ChatHooks(on_save_turn_messages=filter_fn)",
66
- "on_save_turn_messages hook filters turn messages before saving to session history — useful for excluding transient messages injected by hooks",
67
- "Transient message pattern: mark messages with metadata={'transient': True}, then filter in on_save_turn_messages hook",
65
+ "HookRegistry from flowra.agent provides generic Event[T] + handler pattern for lifecycle hooks. Register handlers via hooks.on(EventType, handler). Supports multiple handlers, sync/async transparency, HookProvider composition, and propagate_to() for isolation",
66
+ "SaveTurnMessagesEvent filters turn messages before saving to session history — mutate event.data.messages in place to control what gets persisted. Useful for excluding transient messages injected by hooks",
67
+ "Transient message pattern: mark messages with metadata={'transient': True}, then filter in SaveTurnMessagesEvent handler",
68
68
 
69
69
  "PRE-BUILT AGENTS — ToolLoopAgent: single-turn LLM tool loop with hooks and caching",
70
70
  "ToolLoopAgent sends messages to LLM, executes tool calls, feeds results back, repeats until done",
71
71
  "ToolLoopConfig configures ToolLoopAgent: ToolLoopConfig(llm_config=LLMConfig(model='...'), cache_config=CacheConfig(...))",
72
- "ToolLoopHooks provides lifecycle callbacks: on_user_message, on_message_accepted, on_start_iteration, on_before_llm_call, on_text_delta, on_thinking_delta, on_after_llm_call, on_text_reasoning, on_thinking, on_result_message, on_before_tool_call, on_after_tool_call",
73
- "When on_text_delta or on_thinking_delta hooks are set, ToolLoopAgent automatically uses provider.stream() instead of provider.call()",
72
+ "ToolLoopAgent hook events: UserMessageEvent, MessageAcceptedEvent, StartIterationEvent, BeforeLLMCallEvent, TextDeltaEvent, ThinkingDeltaEvent, AfterLLMCallEvent, TextReasoningEvent, ThinkingEvent, ResultMessageEvent, BeforeToolCallEvent, AfterToolCallEvent",
73
+ "When TextDeltaEvent or ThinkingDeltaEvent handlers are registered, ToolLoopAgent automatically uses provider.stream() instead of provider.call()",
74
74
 
75
75
  "CACHING: CacheConfig(system_prompt, tools, messages) controls prompt caching strategies",
76
76
  "Predefined configs: CACHE_ALL, CACHE_SESSION, CACHE_MANUAL, NO_CACHE",
@@ -80,7 +80,7 @@
80
80
  "ConfigValue[T] wraps static or dynamic (callable) config values: ConfigValue[str] | ConfigValue[Callable[[], str]]",
81
81
 
82
82
  "QUICK START: Create provider -> create ToolRegistry -> create Config -> create AgentRuntime -> runtime.run()",
83
- "Import ChatAgent: from flowra.lib.chat import ChatAgent, ChatConfig, ChatHooks, ChatResult, ChatSpec",
83
+ "Import ChatAgent: from flowra.lib.chat import ChatAgent, ChatConfig, ChatResult, ChatSpec, SaveTurnMessagesEvent",
84
84
  "Import LLMConfig: from flowra.lib import LLMConfig",
85
85
  "Import LLM types: from flowra.llm import LLMProvider, SystemMessage, TextBlock, Usage, TextDelta, ThinkingDelta, ContentComplete",
86
86
  "Import runtime: from flowra.runtime import AgentRuntime, FileSessionStorage",
@@ -56,7 +56,8 @@ Pre-built agents for common patterns:
56
56
  - **`ToolLoopAgent`** — single-turn LLM tool loop. Sends messages to the LLM,
57
57
  executes tool calls, feeds results back, repeats until done.
58
58
 
59
- Also provides lifecycle hooks, prompt caching strategies, and dynamic config. → [docs/lib.md](lib.md)
59
+ Also provides a generic `HookRegistry` + `Event[T]` hook system, prompt caching strategies,
60
+ and dynamic config. → [docs/lib.md](lib.md)
60
61
 
61
62
  ## Data flow
62
63
 
@@ -11,14 +11,12 @@ flowra/lib/
11
11
  ├── chat/
12
12
  │ ├── agent.py # ChatAgent — multi-turn chat with session history
13
13
  │ ├── config.py # ChatConfig — chat-level configuration
14
- │ ├── hooks.py # ChatHooks — chat-level lifecycle hooks
15
- │ ├── hook_executor.py # Internal: async/sync hook dispatch
14
+ │ ├── hook_events.py # SaveTurnMessagesEvent
16
15
  │ └── spec.py # ChatSpec, ChatResult
17
16
  └── tool_loop/
18
17
  ├── agent.py # ToolLoopAgent — single-turn LLM tool loop
19
18
  ├── config.py # ToolLoopConfig
20
- ├── hooks.py # ToolLoopHooks, hook protocols and result types
21
- ├── hook_executor.py # Internal: async/sync hook dispatch
19
+ ├── hook_events.py # 12 event dataclasses for hook system
22
20
  ├── spec.py # ToolLoopSpec, ToolLoopResult, ToolLoopStatus
23
21
  ├── context.py # ToolLoopAgentContext — tool-accessible loop context
24
22
  ├── cache.py # CacheConfig, cache strategies and presets
@@ -114,10 +112,10 @@ Input for `ChatAgent.process_message`.
114
112
  | Field | Type | Default |
115
113
  |----------------|--------------------------|---------|
116
114
  | `user_message` | `str` | — |
117
- | `meta` | `dict[str, Any] \| None` | `None` |
115
+ | `metadata` | `dict[str, Any] \| None` | `None` |
118
116
 
119
- `meta` is passed through to `ToolLoopSpec` and delivered to the `on_message_accepted`
120
- hook — useful for tracking message IDs in interrupt scenarios.
117
+ `metadata` is passed through to `ToolLoopSpec` and delivered to `MessageAcceptedEvent`
118
+ — useful for tracking message IDs in interrupt scenarios.
121
119
 
122
120
  ### `ChatResult`
123
121
 
@@ -151,31 +149,32 @@ sync callable, or async callable (see [ConfigValue](#configvalue) below).
151
149
  instead of `session_messages`. The chat agent builds `session_messages` automatically
152
150
  by prepending `system_messages` to the accumulated conversation history.
153
151
 
154
- ### `ChatHooks`
152
+ ### Chat hooks
155
153
 
156
- Chat-level lifecycle hooks, injected via DI as a service (same pattern as `ToolLoopHooks`).
154
+ Chat-level hook events, registered on the same `HookRegistry` instance as tool loop hooks.
157
155
 
158
- | Field | Type | Default |
159
- |--------------------------|----------------------------------------------------------------|---------|
160
- | `on_save_turn_messages` | `OnSaveTurnMessages \| OnSaveTurnMessagesAsync \| None` | `None` |
156
+ **`SaveTurnMessagesEvent`** emitted before turn messages are saved into session history.
157
+ Handlers mutate `messages` in place to control what gets persisted. Useful for excluding
158
+ transient messages (e.g. dynamic context injected by hooks) from long-term session history.
161
159
 
162
- **`on_save_turn_messages`** called before turn messages are saved into session history.
163
- Receives the full list of turn messages and returns the filtered list to persist.
164
- Useful for excluding transient messages (e.g. dynamic context injected by hooks)
165
- from long-term session history.
160
+ | Field | Type | Default |
161
+ |---------------------|---------------------------|---------|
162
+ | `messages` | `list[Message]` | — |
166
163
 
167
164
  ```python
168
- from flowra.lib.chat import ChatAgent, ChatConfig, ChatHooks
165
+ from flowra.agent import HookRegistry
166
+ from flowra.lib.chat import ChatAgent, ChatConfig, SaveTurnMessagesEvent
169
167
  from flowra.llm import Message
170
168
 
171
- def filter_transient(messages: list[Message]) -> list[Message]:
172
- return [m for m in messages if not m.metadata.get("transient")]
169
+ def filter_transient(event):
170
+ event.data.messages = [m for m in event.data.messages if not m.metadata.get("transient")]
173
171
 
174
- hooks = ChatHooks(on_save_turn_messages=filter_transient)
172
+ hooks = HookRegistry()
173
+ hooks.on(SaveTurnMessagesEvent, filter_transient)
175
174
 
176
175
  runtime = AgentRuntime(
177
176
  agents={"chat": ChatAgent},
178
- services={..., ChatConfig: config, ChatHooks: hooks},
177
+ services={..., ChatConfig: config, HookRegistry: hooks},
179
178
  )
180
179
  ```
181
180
 
@@ -217,7 +216,7 @@ Input for `ToolLoopAgent.start`.
217
216
  | Field | Type | Default |
218
217
  |----------------|--------------------------|---------|
219
218
  | `user_message` | `str` | — |
220
- | `meta` | `dict[str, Any] \| None` | `None` |
219
+ | `metadata` | `dict[str, Any] \| None` | `None` |
221
220
 
222
221
  ### `ToolLoopResult`
223
222
 
@@ -278,99 +277,78 @@ the agent tells the LLM to try a different approach.
278
277
 
279
278
  ## Hooks
280
279
 
281
- All lifecycle hooks are grouped into the `ToolLoopHooks` dataclass, injected via DI as
282
- a service. Every hook is optional, and each accepts either a sync or async callable.
280
+ Lifecycle hooks use a generic `HookRegistry` + `Event[T]` pattern from `flowra.agent`.
281
+ Register handlers for typed event dataclasses. Supports multiple handlers per event,
282
+ sync/async transparency, and composition via `HookProvider`.
283
283
 
284
284
  ```python
285
- from flowra.lib.tool_loop import ToolLoopHooks
285
+ from flowra.agent import HookRegistry
286
+ from flowra.lib.tool_loop import BeforeLLMCallEvent, AfterLLMCallEvent
286
287
 
287
- hooks = ToolLoopHooks(
288
- on_before_llm_call=lambda req, ctx: print(f"Calling LLM with {len(req.messages)} messages"),
289
- on_after_llm_call=lambda req, res, ctx: print(f"LLM returned {res.stop_reason}"),
290
- )
288
+ hooks = HookRegistry()
289
+ hooks.on(BeforeLLMCallEvent, lambda e: print(f"Calling LLM with {len(e.data.request.messages)} messages"))
290
+ hooks.on(AfterLLMCallEvent, lambda e: print(f"LLM returned {e.data.response.stop_reason}"))
291
291
 
292
292
  runtime = AgentRuntime(
293
293
  agents={"chat": ChatAgent},
294
- services={..., ToolLoopHooks: hooks},
294
+ services={..., HookRegistry: hooks},
295
295
  )
296
296
  ```
297
297
 
298
- ### `ToolLoopHooks`
299
-
300
- | Field | Type | Default |
301
- |------------------------|----------------------------------------------------------------|---------|
302
- | `on_user_message` | `OnUserMessage \| OnUserMessageAsync \| None` | `None` |
303
- | `on_message_accepted` | `OnMessageAccepted \| OnMessageAcceptedAsync \| None` | `None` |
304
- | `on_start_iteration` | `OnStartIteration \| OnStartIterationAsync \| None` | `None` |
305
- | `on_before_llm_call` | `OnBeforeLLMCall \| OnBeforeLLMCallAsync \| None` | `None` |
306
- | `on_after_llm_call` | `OnAfterLLMCall \| OnAfterLLMCallAsync \| None` | `None` |
307
- | `on_result_message` | `OnResultMessage \| OnResultMessageAsync \| None` | `None` |
308
- | `on_text_delta` | `OnTextDelta \| OnTextDeltaAsync \| None` | `None` |
309
- | `on_thinking_delta` | `OnThinkingDelta \| OnThinkingDeltaAsync \| None` | `None` |
310
- | `on_text_reasoning` | `OnTextReasoning \| OnTextReasoningAsync \| None` | `None` |
311
- | `on_thinking` | `OnThinking \| OnThinkingAsync \| None` | `None` |
312
- | `on_before_tool_call` | `OnBeforeToolCall \| OnBeforeToolCallAsync \| None` | `None` |
313
- | `on_after_tool_call` | `OnAfterToolCall \| OnAfterToolCallAsync \| None` | `None` |
298
+ ### `HookRegistry`
299
+
300
+ | Method | Description |
301
+ |---------------------------------------------|--------------------------------------------------|
302
+ | `on(event_type, handler, *, prepend=False)` | Register a handler for an event type |
303
+ | `has_handlers(event_type) -> bool` | Check if any handlers are registered |
304
+ | `add(provider: HookProvider)` | Register all hooks from a HookProvider |
305
+ | `async emit(data: T) -> T` | Wrap data in `Event`, call handlers, return data |
306
+ | `propagate_to(target, *, only, exclude)` | Forward events to another registry |
307
+
308
+ Handlers receive `Event[T]` where `T` is the event dataclass. `Event` has two fields:
309
+ `data: T` (the event payload) and `context: HookContext | None` (metadata stamped by emit).
310
+ `HookContext` carries `agent_type: type[AbstractAgent] | None` and `agent_path: str`.
311
+ Handlers can mutate `event.data` fields to communicate results back.
312
+
313
+ ### Hook events
314
+
315
+ | Event | Mutable fields | Description |
316
+ |------------------------|--------------------------------------------------|---------------------------------------------------------------------------|
317
+ | `UserMessageEvent` | `messages: list[UserMessage]` | Once at loop start. Mutate to replace or augment the initial messages. |
318
+ | `MessageAcceptedEvent` | — | After messages accepted (incl. on resume). Has `messages: list[Message]`. |
319
+ | `StartIterationEvent` | `additional_messages: list[UserMessage]` | At start of each iteration. Append to inject context before LLM call. |
320
+ | `BeforeLLMCallEvent` | — | Before each LLM request. Has `request`, `context`. |
321
+ | `TextDeltaEvent` | — | During streaming. Has `text` (str), `context`. |
322
+ | `ThinkingDeltaEvent` | — | During streaming. Has `text` (str), `context`. |
323
+ | `AfterLLMCallEvent` | — | After each LLM response. Has `request`, `response`, `context`. |
324
+ | `TextReasoningEvent` | — | For each `TextBlock` in response. Has `text` (TextBlock), `context`. |
325
+ | `ThinkingEvent` | — | For each `ThinkingBlock` in response. Has `thinking`, `context`. |
326
+ | `BeforeToolCallEvent` | `tool_use: ToolUseBlock` | Before tool execution. Replace to modify tool parameters. |
327
+ | `AfterToolCallEvent` | `result: ToolResultBlock`, `additional_messages` | After tool execution. Replace result or inject messages. |
328
+ | `ResultMessageEvent` | `continue_messages: list[UserMessage]` | On END_TURN. Set to continue loop instead of finishing. |
314
329
 
315
330
  ### Hook lifecycle
316
331
 
317
- Hooks fire in this order during a single tool loop iteration:
318
-
319
- 1. **`on_user_message`** — once at loop start, before the first iteration. Receives the
320
- `UserMessage` built from the spec. Return `UserMessageResult(messages=[...])` to
321
- replace the initial messages (include the original in the list to keep it).
322
-
323
- 2. **`on_message_accepted`** — once after the user message is persisted via `flush()`.
324
- Receives `meta` from the spec. Useful for tracking accepted message IDs.
325
-
326
- 3. **`on_start_iteration`** — at the start of each iteration. Receives 1-indexed
327
- iteration number and `ToolLoopAgentContext`. Return `StartIterationResult` with
328
- `additional_messages` to inject context before the LLM call.
329
-
330
- 4. **`on_before_llm_call`** — before each LLM request. Receives `LLMRequest` and context.
331
- Observational only (no return value).
332
+ Events fire in this order during a single tool loop iteration:
332
333
 
333
- 5. **`on_text_delta`** / **`on_thinking_delta`** when either hook is set, the agent
334
- uses `provider.stream()` instead of `provider.call()`. `on_text_delta` fires for
335
- each incremental text chunk; `on_thinking_delta` fires for each thinking chunk.
336
- These fire **during** the LLM call, before `on_after_llm_call`. The stream is
337
- wrapped with `with_interrupt`, so an `InterruptToken` signal exits immediately
338
- even if the LLM is slow to produce the next token.
339
-
340
- 6. **`on_after_llm_call`** — after each LLM response. Receives `LLMRequest`, `LLMResponse`,
341
- and context. Observational only.
342
-
343
- 7. **`on_text_reasoning`** — for each `TextBlock` in the assistant response. Fires
344
- regardless of stop reason — useful for observing text output even when tool calls
345
- are also present.
346
-
347
- 8. **`on_thinking`** — for each `ThinkingBlock` in the assistant response. Fires
348
- for models with thinking/reasoning enabled (e.g. extended thinking).
334
+ 1. **`UserMessageEvent`** — once at loop start, before the first iteration.
335
+ 2. **`MessageAcceptedEvent`** once after messages are accepted (including on resume).
336
+ 3. **`StartIterationEvent`** at the start of each iteration.
337
+ 4. **`BeforeLLMCallEvent`** before each LLM request.
338
+ 5. **`TextDeltaEvent`** / **`ThinkingDeltaEvent`** when handlers are registered for either,
339
+ the agent uses `provider.stream()` instead of `provider.call()`. These fire **during**
340
+ the LLM call, before `AfterLLMCallEvent`.
341
+ 6. **`AfterLLMCallEvent`** — after each LLM response.
342
+ 7. **`TextReasoningEvent`** — for each `TextBlock` in the assistant response.
343
+ 8. **`ThinkingEvent`** — for each `ThinkingBlock` in the assistant response.
349
344
 
350
345
  Then the flow branches based on stop reason:
351
346
 
352
347
  - **If `TOOL_USE`:**
353
-
354
- 9. **`on_before_tool_call`** — before each tool execution. Return `BeforeToolCallResult`
355
- with `amended_tool_use` to modify tool parameters.
356
-
357
- 10. **`on_after_tool_call`** — after each tool execution. Return `AfterToolCallResult`
358
- with `amended_result` and/or `additional_messages`.
359
-
348
+ 9. **`BeforeToolCallEvent`** — before each tool execution.
349
+ 10. **`AfterToolCallEvent`** — after each tool execution.
360
350
  - **If `END_TURN`:**
361
-
362
- 11. **`on_result_message`** — return `ResultMessageResult` with `continue_messages`
363
- to force the loop to continue instead of finishing.
364
-
365
- ### Hook result types
366
-
367
- | Type | Fields |
368
- |--------------------------|-------------------------------------------------------------------------------------|
369
- | `UserMessageResult` | `messages: list[UserMessage]` |
370
- | `StartIterationResult` | `additional_messages: list[UserMessage]` (default `[]`) |
371
- | `BeforeToolCallResult` | `amended_tool_use: ToolUseBlock \| None` (default `None`) |
372
- | `AfterToolCallResult` | `amended_result: ToolResultBlock \| None`, `additional_messages: list[UserMessage]` |
373
- | `ResultMessageResult` | `continue_messages: list[UserMessage]` (default `[]`) |
351
+ 11. **`ResultMessageEvent`** — set `continue_messages` to force the loop to continue.
374
352
 
375
353
  ## Prompt caching
376
354
 
@@ -515,23 +493,6 @@ value = await resolve_config_value(config.max_iterations) # always returns T
515
493
  ## Type aliases
516
494
 
517
495
  ```python
518
- # Chat hook protocols (each has sync and async variant)
519
- OnSaveTurnMessages / OnSaveTurnMessagesAsync
520
-
521
- # Tool loop hook protocols (each has sync and async variant)
522
- OnUserMessage / OnUserMessageAsync
523
- OnMessageAccepted / OnMessageAcceptedAsync
524
- OnStartIteration / OnStartIterationAsync
525
- OnBeforeLLMCall / OnBeforeLLMCallAsync
526
- OnAfterLLMCall / OnAfterLLMCallAsync
527
- OnTextDelta / OnTextDeltaAsync
528
- OnThinkingDelta / OnThinkingDeltaAsync
529
- OnTextReasoning / OnTextReasoningAsync
530
- OnThinking / OnThinkingAsync
531
- OnResultMessage / OnResultMessageAsync
532
- OnBeforeToolCall / OnBeforeToolCallAsync
533
- OnAfterToolCall / OnAfterToolCallAsync
534
-
535
496
  # Cache strategy protocols
536
497
  SystemPromptCacheStrategy # (messages: list[SystemMessage]) -> list[SystemMessage]
537
498
  ToolsCacheStrategy # (tools: list[Tool]) -> list[Tool]
@@ -554,23 +515,16 @@ ChatAgent
554
515
 
555
516
  ### Tool loop flow
556
517
 
557
- 1. `start` — accepts user message, runs `on_user_message` hook, appends to
558
- `turn_messages`, flushes, fires `on_message_accepted`, then gotos `call_llm`.
559
- 2. `call_llm` — checks interrupt/finish/max_iterations, runs `on_start_iteration`,
560
- builds `LLMRequest`, runs `on_before_llm_call`, calls LLM (streaming deltas via
561
- `on_text_delta`/`on_thinking_delta` if set), runs `on_after_llm_call`,
562
- `on_text_reasoning`, and `on_thinking`, then:
563
- - `END_TURN` → runs `on_result_message`, returns `ToolLoopResult` (or continues
518
+ 1. `start` — accepts user message, emits `UserMessageEvent`, appends to
519
+ `turn_messages`, flushes, emits `MessageAcceptedEvent`, then gotos `call_llm`.
520
+ 2. `call_llm` — checks interrupt/finish/max_iterations, emits `StartIterationEvent`,
521
+ builds `LLMRequest`, emits `BeforeLLMCallEvent`, calls LLM (streaming deltas via
522
+ `TextDeltaEvent`/`ThinkingDeltaEvent` if handlers registered), emits `AfterLLMCallEvent`,
523
+ `TextReasoningEvent`, and `ThinkingEvent`, then:
524
+ - `END_TURN` → emits `ResultMessageEvent`, returns `ToolLoopResult` (or continues
564
525
  if `continue_messages` is non-empty)
565
- - `TOOL_USE` → runs `on_before_tool_call` for each tool, spawns `ToolCallAgent`
526
+ - `TOOL_USE` → emits `BeforeToolCallEvent` for each tool, spawns `ToolCallAgent`
566
527
  children in parallel
567
- 3. `collect_results` — gathers results from `ToolCallAgent` children, runs
568
- `on_after_tool_call` for each, applies `max_consecutive_errors` logic,
528
+ 3. `collect_results` — gathers results from `ToolCallAgent` children, emits
529
+ `AfterToolCallEvent` for each, applies `max_consecutive_errors` logic,
569
530
  appends tool results to `turn_messages`, gotos `call_llm`.
570
-
571
- ### Hook executor
572
-
573
- `hook_executor.py` provides `execute_*_hook()` functions that handle the sync/async
574
- dispatch transparently — each function calls the hook, checks `inspect.isawaitable()`,
575
- and awaits if needed. This allows hooks to be plain functions or async functions
576
- interchangeably.
@@ -0,0 +1,273 @@
1
+ # Hook System Redesign — Research
2
+
3
+ Research date: 2026-03-08
4
+
5
+ ## Problem
6
+
7
+ Current hook system in `flowra/lib/tool_loop/hooks.py`:
8
+
9
+ 1. **24 Protocol classes** (12 hooks × sync/async variants) — massive boilerplate
10
+ 2. **12 executor functions** in `hook_executor.py` — nearly identical code
11
+ 3. **5 result dataclasses** — separate types for hook results
12
+ 4. **Single handler per hook** — no composition (can't have OTel + logging + guardrails)
13
+ 5. No isolation — parent agents can't intercept/filter child agent events
14
+
15
+
16
+ ## Design decisions
17
+
18
+ ### Event[T] — generic envelope
19
+
20
+ Handler always receives `Event[T]` — one generic type, no signature inspection magic,
21
+ works naturally with lambdas:
22
+
23
+ ```python
24
+ @dataclass
25
+ class Event[T]:
26
+ data: T
27
+ context: HookContext | None = None
28
+
29
+
30
+ @dataclass
31
+ class HookContext:
32
+ agent: str # "ResearchAgent"
33
+ agent_path: str # "coordinator.research"
34
+ execution_path: str | None # "run_123.step_5.spawn_2"
35
+ ```
36
+
37
+ HookRegistry stamps context automatically on emit. Emitting code doesn't know about context.
38
+
39
+
40
+ ### Event data — plain dataclasses with mutable result fields
41
+
42
+ Each hook point is a dataclass. Contains input args and mutable fields for results.
43
+ No Protocol classes, no separate result types:
44
+
45
+ ```python
46
+ @dataclass
47
+ class BeforeLLMCall:
48
+ request: LLMRequest
49
+ context: ToolLoopAgentContext
50
+
51
+ @dataclass
52
+ class BeforeToolCall:
53
+ tool_use: ToolUseBlock
54
+ context: ToolLoopAgentContext
55
+ amended_tool_use: ToolUseBlock | None = None # handler can set this
56
+
57
+ @dataclass
58
+ class UserMessageEvent:
59
+ user_message: UserMessage
60
+ replacement_messages: list[UserMessage] | None = None # handler can set this
61
+
62
+ @dataclass
63
+ class TextDeltaEvent:
64
+ text: str
65
+ context: ToolLoopAgentContext
66
+ ```
67
+
68
+
69
+ ### HookRegistry — flat, no hierarchy
70
+
71
+ No parent/child relationship inside HookRegistry. No bubbling. No propagate flag.
72
+ Isolation is handled explicitly by creating separate registries and wiring them manually.
73
+
74
+ ```python
75
+ class HookRegistry:
76
+ def __init__(self, context: HookContext | None = None):
77
+ self._context = context
78
+ self._handlers: dict[type, list[Callable]] = {}
79
+
80
+ def on[E](self, event_type: type[E], handler: Callable[[Event[E]], Any], *,
81
+ prepend: bool = False) -> None:
82
+ """Add handler. Append by default, prepend=True to go first."""
83
+ handlers = self._handlers.setdefault(event_type, [])
84
+ if prepend:
85
+ handlers.insert(0, handler)
86
+ else:
87
+ handlers.append(handler)
88
+
89
+ def has_handlers(self, event_type: type) -> bool:
90
+ """Check if any handlers registered (used for streaming decision)."""
91
+ return bool(self._handlers.get(event_type))
92
+
93
+ def add(self, provider: HookProvider) -> None:
94
+ """Register all hooks from a provider."""
95
+ provider.register_hooks(self)
96
+
97
+ async def emit[T](self, data: T) -> T:
98
+ """Emit event: wrap in Event[T], call all handlers in order."""
99
+ event = Event(data=data, context=self._context)
100
+ for handler in self._handlers.get(type(data), []):
101
+ result = handler(event)
102
+ if inspect.isawaitable(result):
103
+ await result
104
+ return data
105
+
106
+ def propagate_to(self, target: HookRegistry, *,
107
+ only: list[type] | None = None,
108
+ exclude: list[type] | None = None) -> None:
109
+ """Subscribe to events and re-emit them to target registry."""
110
+ event_types = only or ALL_EVENT_TYPES
111
+ if exclude:
112
+ event_types = [t for t in event_types if t not in exclude]
113
+ for event_type in event_types:
114
+ self.on(event_type, lambda e, _t=target: _t.emit(e.data))
115
+ ```
116
+
117
+
118
+ ### HookProvider — protocol for grouping related hooks
119
+
120
+ Distributable hook sets (OTel, logging, guardrails):
121
+
122
+ ```python
123
+ class HookProvider(Protocol):
124
+ def register_hooks(self, registry: HookRegistry) -> None: ...
125
+ ```
126
+
127
+ ```python
128
+ class OtelHooks(HookProvider):
129
+ def __init__(self, tracer: Tracer):
130
+ self._tracer = tracer
131
+
132
+ def register_hooks(self, registry: HookRegistry) -> None:
133
+ registry.on(BeforeLLMCall, self._start_span)
134
+ registry.on(AfterLLMCall, self._end_span)
135
+
136
+ async def _start_span(self, event: Event[BeforeLLMCall]) -> None:
137
+ self._span = self._tracer.start("llm_call", model=event.data.request.model)
138
+
139
+ async def _end_span(self, event: Event[AfterLLMCall]) -> None:
140
+ if self._span:
141
+ self._span.end(tokens=event.data.response.usage)
142
+ ```
143
+
144
+
145
+ ### Handler styles — all work
146
+
147
+ ```python
148
+ # Lambda
149
+ hooks.on(BeforeLLMCall, lambda e: print(e.data.request.model))
150
+
151
+ # Function
152
+ def on_llm(e: Event[BeforeLLMCall]) -> None:
153
+ print(f"{e.context.agent_path}: {e.data.request.model}")
154
+
155
+ # Async function
156
+ async def on_llm(e: Event[BeforeLLMCall]) -> None:
157
+ await tracer.start_span(e.data.request.model)
158
+
159
+ # Method in HookProvider
160
+ class MyHooks(HookProvider):
161
+ def register_hooks(self, registry: HookRegistry) -> None:
162
+ registry.on(BeforeLLMCall, self._on_llm)
163
+ def _on_llm(self, event: Event[BeforeLLMCall]) -> None: ...
164
+ ```
165
+
166
+
167
+ ### Ordering
168
+
169
+ - `on(EventType, handler)` — appends (default)
170
+ - `on(EventType, handler, prepend=True)` — inserts at beginning
171
+ - All events — same order (registration order). No reverse for "after" events.
172
+ - No wrapping semantics. Hooks are observers, not middleware.
173
+
174
+
175
+ ### Child agent isolation — explicit, no magic
176
+
177
+ No hierarchy inside HookRegistry. Parent creates a separate registry for children
178
+ and wires it explicitly:
179
+
180
+ ```python
181
+ def __init__(self, hooks: HookRegistry, services: ServiceLocator, ...):
182
+ self._hooks = hooks
183
+
184
+ # Children get a separate registry
185
+ child_hooks = HookRegistry()
186
+
187
+ # Subscribe to what I care about
188
+ child_hooks.on(BeforeLLMCall, self._on_child_llm)
189
+
190
+ # Propagate selected events to my registry
191
+ child_hooks.propagate_to(hooks, exclude=[ThinkingDelta])
192
+
193
+ # Provide to children via DI
194
+ services.provide(HookRegistry, child_hooks)
195
+ ```
196
+
197
+ Three modes:
198
+ - **No isolation**: provide same registry to children — they emit into it directly
199
+ - **Full isolation**: `HookRegistry()` without propagation — nothing escapes
200
+ - **Selective**: `propagate_to(hooks, exclude=[...])` — filter what goes up
201
+
202
+
203
+ ### HookContext — stamped by registry, not by emitting code
204
+
205
+ HookRegistry is created with a HookContext. The runtime/execution engine sets it
206
+ when creating registries for agent execution nodes:
207
+
208
+ ```python
209
+ hooks = HookRegistry(context=HookContext(
210
+ agent="ResearchAgent",
211
+ agent_path="coordinator.research",
212
+ execution_path="run_42.step_3.spawn_1",
213
+ ))
214
+ services.provide(HookRegistry, hooks)
215
+ ```
216
+
217
+ Agent code just emits data:
218
+ ```python
219
+ await self._hooks.emit(BeforeLLMCall(request=request))
220
+ ```
221
+
222
+ Handler sees context:
223
+ ```python
224
+ hooks.on(BeforeLLMCall, lambda e: print(f"[{e.context.agent_path}] LLM call"))
225
+ ```
226
+
227
+ HookContext is orthogonal to hook routing — it's informational metadata for
228
+ observability, not a mechanism for controlling event flow.
229
+
230
+
231
+ ### Error handling
232
+
233
+ Exceptions from handlers propagate as-is. No catching, no logging, no suppression.
234
+ If a handler raises — it's a bug, let it surface. Users can add their own try/except
235
+ in handlers if they want resilience.
236
+
237
+
238
+ ### Agent usage — in ToolLoopAgent
239
+
240
+ ```python
241
+ # Before (current):
242
+ await execute_before_llm_call_hook(self.__hooks.on_before_llm_call, request, self.__context)
243
+
244
+ # After (new):
245
+ await self.__hooks.emit(BeforeLLMCall(request=request, context=self.__context))
246
+
247
+ # Streaming decision (current):
248
+ if self.__hooks.on_text_delta is not None or self.__hooks.on_thinking_delta is not None:
249
+
250
+ # After (new):
251
+ if self.__hooks.has_handlers(TextDeltaEvent) or self.__hooks.has_handlers(ThinkingDeltaEvent):
252
+ ```
253
+
254
+
255
+ ## What gets eliminated
256
+
257
+ | Before | After |
258
+ |---|---|
259
+ | 24 Protocol classes (sync + async) | 0 |
260
+ | 12 executor functions | 1 generic `emit()` |
261
+ | 5 Result dataclasses | 0 — mutable fields on event data |
262
+ | `ToolLoopHooks` with 12 fields | `HookRegistry` with one dict |
263
+ | Single handler per hook | Multiple handlers, ordered |
264
+ | No composition | `HookProvider`, `propagate_to()` |
265
+ | No isolation | Separate registries, explicit wiring |
266
+
267
+
268
+ ## Future ideas (not for initial implementation)
269
+
270
+ - **Filters on registration**: `hooks.on(BeforeLLMCall, handler, filter=lambda e: ...)`
271
+ to skip handler based on event/context without running the handler.
272
+ - **Typed propagate_to**: auto-discover all event types instead of maintaining
273
+ `ALL_EVENT_TYPES` list.