flowra 0.0.2.dev5__tar.gz → 0.0.4__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.4}/.gitignore +3 -1
  2. {flowra-0.0.2.dev5 → flowra-0.0.4}/CHANGELOG.md +19 -1
  3. {flowra-0.0.2.dev5 → flowra-0.0.4}/CLAUDE.md +33 -0
  4. {flowra-0.0.2.dev5 → flowra-0.0.4}/PKG-INFO +1 -1
  5. {flowra-0.0.2.dev5 → flowra-0.0.4}/context7.json +6 -6
  6. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/architecture.md +2 -1
  7. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/lib.md +85 -131
  8. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/llm.md +5 -2
  9. flowra-0.0.4/docs/research/hooks_redesign.md +273 -0
  10. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/research/strands_comparison.md +4 -4
  11. flowra-0.0.4/docs/research/voice_stt.md +105 -0
  12. flowra-0.0.4/docs/todo.md +126 -0
  13. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/console_chat.py +9 -14
  14. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/llm_logging.py +5 -4
  15. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/tui_chat.py +48 -44
  16. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/__init__.py +5 -0
  17. flowra-0.0.4/flowra/agent/hooks.py +113 -0
  18. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/chat/__init__.py +2 -4
  19. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/chat/agent.py +8 -11
  20. flowra-0.0.4/flowra/lib/chat/hook_events.py +23 -0
  21. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/chat/spec.py +1 -1
  22. flowra-0.0.4/flowra/lib/tool_loop/__init__.py +71 -0
  23. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/agent.py +50 -56
  24. flowra-0.0.4/flowra/lib/tool_loop/hook_events.py +178 -0
  25. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/spec.py +1 -1
  26. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/providers/anthropic_vertex.py +8 -4
  27. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/schema_validation.py +8 -5
  28. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/version.py +1 -1
  29. flowra-0.0.4/tests/agent/test_hooks.py +167 -0
  30. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/test_chat_agent.py +45 -34
  31. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/test_tool_loop_agent.py +127 -125
  32. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_anthropic_e2e.py +2 -2
  33. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_anthropic_vertex.py +61 -1
  34. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/test_schema_validation.py +25 -23
  35. flowra-0.0.2.dev5/docs/todo.md +0 -22
  36. flowra-0.0.2.dev5/flowra/lib/chat/hook_executor.py +0 -21
  37. flowra-0.0.2.dev5/flowra/lib/chat/hooks.py +0 -43
  38. flowra-0.0.2.dev5/flowra/lib/tool_loop/__init__.py +0 -107
  39. flowra-0.0.2.dev5/flowra/lib/tool_loop/hook_executor.py +0 -221
  40. flowra-0.0.2.dev5/flowra/lib/tool_loop/hooks.py +0 -479
  41. {flowra-0.0.2.dev5 → flowra-0.0.4}/.claude/commands/update-pricing.md +0 -0
  42. {flowra-0.0.2.dev5 → flowra-0.0.4}/.env.example +0 -0
  43. {flowra-0.0.2.dev5 → flowra-0.0.4}/.github/workflows/master.yml +0 -0
  44. {flowra-0.0.2.dev5 → flowra-0.0.4}/.github/workflows/publish.yml +0 -0
  45. {flowra-0.0.2.dev5 → flowra-0.0.4}/.github/workflows/pull_request.yml +0 -0
  46. {flowra-0.0.2.dev5 → flowra-0.0.4}/.github/workflows/pull_request_e2e.yml +0 -0
  47. {flowra-0.0.2.dev5 → flowra-0.0.4}/.python-version +0 -0
  48. {flowra-0.0.2.dev5 → flowra-0.0.4}/LICENSE +0 -0
  49. {flowra-0.0.2.dev5 → flowra-0.0.4}/Makefile +0 -0
  50. {flowra-0.0.2.dev5 → flowra-0.0.4}/README.md +0 -0
  51. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/agent.md +0 -0
  52. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_plan.md +0 -0
  53. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step1_structure.md +0 -0
  54. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step2_code_style.md +0 -0
  55. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step3_documentation.md +0 -0
  56. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step4_doc_readability.md +0 -0
  57. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step5_doc_audit.md +0 -0
  58. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/review_prompts/step6_tests.md +0 -0
  59. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/runtime.md +0 -0
  60. {flowra-0.0.2.dev5 → flowra-0.0.4}/docs/tools.md +0 -0
  61. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/__init__.py +0 -0
  62. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/app_agent.py +0 -0
  63. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/llm_routing.py +0 -0
  64. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/menu_agent.py +0 -0
  65. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/menu_agent_class.py +0 -0
  66. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/model_registry.py +0 -0
  67. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/system_prompt.txt +0 -0
  68. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/tools/__init__.py +0 -0
  69. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/tools/calculator.py +0 -0
  70. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/tools/random_numbers.py +0 -0
  71. {flowra-0.0.2.dev5 → flowra-0.0.4}/examples/tools/switch_model.py +0 -0
  72. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/__init__.py +0 -0
  73. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/agent.py +0 -0
  74. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/agent_def.py +0 -0
  75. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/agent_registry.py +0 -0
  76. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/agent_store.py +0 -0
  77. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/compile.py +0 -0
  78. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/interrupt_helpers.py +0 -0
  79. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/interrupt_token.py +0 -0
  80. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/service_locator.py +0 -0
  81. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/step_decorator.py +0 -0
  82. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/agent/stored_values.py +0 -0
  83. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/__init__.py +0 -0
  84. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/chat/config.py +0 -0
  85. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/config_value.py +0 -0
  86. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/llm_config.py +0 -0
  87. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/_tool_call_agent.py +0 -0
  88. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/cache.py +0 -0
  89. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/config.py +0 -0
  90. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/lib/tool_loop/context.py +0 -0
  91. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/__init__.py +0 -0
  92. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/_base.py +0 -0
  93. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/blocks.py +0 -0
  94. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/messages.py +0 -0
  95. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/pricing/__init__.py +0 -0
  96. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/pricing/anthropic.py +0 -0
  97. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/pricing/google.py +0 -0
  98. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/pricing/openai.py +0 -0
  99. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/provider.py +0 -0
  100. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/providers/__init__.py +0 -0
  101. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/providers/google_vertex.py +0 -0
  102. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/providers/openai.py +0 -0
  103. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/request.py +0 -0
  104. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/response.py +0 -0
  105. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/schema_formatting.py +0 -0
  106. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/stream.py +0 -0
  107. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/llm/tools.py +0 -0
  108. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/py.typed +0 -0
  109. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/__init__.py +0 -0
  110. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/_sealed_scope.py +0 -0
  111. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/engine.py +0 -0
  112. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/execution.py +0 -0
  113. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/interrupt.py +0 -0
  114. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/runtime.py +0 -0
  115. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/runtime_scope.py +0 -0
  116. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/serialization.py +0 -0
  117. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/storage/__init__.py +0 -0
  118. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/storage/file.py +0 -0
  119. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/storage/in_memory.py +0 -0
  120. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/runtime/storage/session_storage.py +0 -0
  121. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/__init__.py +0 -0
  122. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/local_tool.py +0 -0
  123. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/mcp_connection.py +0 -0
  124. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/tool_group.py +0 -0
  125. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/tool_registry.py +0 -0
  126. {flowra-0.0.2.dev5 → flowra-0.0.4}/flowra/tools/types.py +0 -0
  127. {flowra-0.0.2.dev5 → flowra-0.0.4}/pyproject.toml +0 -0
  128. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/__init__.py +0 -0
  129. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/__init__.py +0 -0
  130. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_agent.py +0 -0
  131. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_agent_def.py +0 -0
  132. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_agent_registry.py +0 -0
  133. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_compile.py +0 -0
  134. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_step_ref.py +0 -0
  135. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_values.py +0 -0
  136. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/agent/test_with_interrupt.py +0 -0
  137. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/__init__.py +0 -0
  138. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/test_config_value.py +0 -0
  139. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/test_tool_call_agent.py +0 -0
  140. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/tool_loop/__init__.py +0 -0
  141. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/lib/tool_loop/test_cache.py +0 -0
  142. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/__init__.py +0 -0
  143. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/pricing/__init__.py +0 -0
  144. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/pricing/test_anthropic.py +0 -0
  145. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/pricing/test_google.py +0 -0
  146. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/pricing/test_openai.py +0 -0
  147. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/__init__.py +0 -0
  148. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_google_vertex.py +0 -0
  149. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_google_vertex_e2e.py +0 -0
  150. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_openai_e2e.py +0 -0
  151. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/providers/test_openai_provider.py +0 -0
  152. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/test_metadata.py +0 -0
  153. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/test_response.py +0 -0
  154. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/test_schema_formatting.py +0 -0
  155. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/llm/test_stream.py +0 -0
  156. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/__init__.py +0 -0
  157. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/storage/__init__.py +0 -0
  158. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/storage/test_file.py +0 -0
  159. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/storage/test_in_memory.py +0 -0
  160. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_engine.py +0 -0
  161. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_interrupt.py +0 -0
  162. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_persistence.py +0 -0
  163. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_runtime.py +0 -0
  164. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_scope.py +0 -0
  165. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/runtime/test_serialization.py +0 -0
  166. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/tools/__init__.py +0 -0
  167. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/tools/test_local_tool.py +0 -0
  168. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/tools/test_mcp_connection.py +0 -0
  169. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/tools/test_tool_group.py +0 -0
  170. {flowra-0.0.2.dev5 → flowra-0.0.4}/tests/tools/test_tool_registry.py +0 -0
  171. {flowra-0.0.2.dev5 → flowra-0.0.4}/uv.lock +0 -0
@@ -12,4 +12,6 @@ build/
12
12
  *.egg
13
13
  /.idea/
14
14
  .chat_sessions/
15
- logs/
15
+ logs/
16
+ .voice_rt_logs/
17
+ .voice_payment_sessions/
@@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.0.4] - 2026-03-09
9
+
10
+ ### Fixed
11
+ - **JSON schema response cleanup**: `AnthropicVertexProvider` now returns a single `TextBlock`
12
+ with clean JSON when `json_schema` is set — markdown fences and thinking blocks are stripped.
13
+ Previously, validated JSON was cleaned internally but the original (fenced) text was returned.
14
+ - `validate_json_schema` now returns `(error, cleaned_text)` tuple so callers get the
15
+ fence-stripped text.
16
+
17
+ ## [0.0.3] - 2026-03-08
18
+
19
+ ### Changed
20
+ - **Hook system redesign**: replaced `ToolLoopHooks`/`ChatHooks` (24 Protocol classes, 12 executor
21
+ functions, 5 result dataclasses) with generic `HookRegistry` + `Event[T]` pattern. Supports
22
+ multiple handlers per event, `HookProvider` for distributable hook sets, and `propagate_to()`
23
+ for parent/child isolation. Hook events are plain mutable dataclasses with result fields.
24
+ - `ToolLoopSpec.meta` / `ChatSpec.meta` renamed to `metadata`.
25
+
26
+ ## [0.0.2] - 2026-03-08
9
27
 
10
28
  ### Added
11
29
  - **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`.
@@ -58,6 +58,39 @@ Review prompts live in `docs/review_prompts/`:
58
58
 
59
59
  Test directory structure mirrors `flowra/`. E2E tests use `_e2e` suffix (e.g., `test_anthropic_e2e.py`). Environment variables loaded from `.env` via Makefile.
60
60
 
61
+ ## Releases
62
+
63
+ CI workflow: `.github/workflows/publish.yml` (manual trigger via `workflow_dispatch`).
64
+
65
+ ### Pre-release
66
+
67
+ No preparation needed. Tell the user to trigger the `publish` workflow with `type: pre-release` in GitHub Actions. Do NOT trigger it yourself. CI appends `.dev{run_number}` to the current version automatically.
68
+
69
+ ### Release
70
+
71
+ **Preparation (manual):**
72
+
73
+ 1. Check that `flowra/version.py` has the correct version (CI bumps it automatically after each release, so it should already be set)
74
+ 2. Rename the `[Unreleased]` section in `CHANGELOG.md` to `[X.Y.Z] - YYYY-MM-DD` matching the version in `version.py`
75
+ 3. Verify documentation is up to date with code changes (especially `docs/llm.md`, `context7.json`)
76
+ 4. Run `make check` — lint + all tests must pass
77
+ 5. Commit, push to `master`
78
+
79
+ **Publishing:**
80
+
81
+ Tell the user to trigger the `publish` workflow manually in GitHub Actions. Do NOT trigger it yourself.
82
+
83
+ CI (`type: release`) will:
84
+ - Validate that the branch is `master`
85
+ - Validate that `CHANGELOG.md` has a section matching the version
86
+ - Run lint + tests
87
+ - Build and publish to PyPI
88
+ - Create git tag `v{version}` and GitHub Release
89
+ - Bump version for next cycle (patch increment) and add `[Unreleased]` section
90
+ - Push the version bump commit to `master`
91
+
92
+ **Do NOT** create git tags manually — CI handles this.
93
+
61
94
  ## Maintenance
62
95
 
63
96
  - **`context7.json`** — project description for [Context7](https://context7.com). Must be updated when adding new features, changing public APIs, or modifying architecture. Keep rules in sync with actual capabilities.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowra
3
- Version: 0.0.2.dev5
3
+ Version: 0.0.4
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.
@@ -525,8 +525,11 @@ print(format_schema_for_llm(schema))
525
525
  #### Schema validation (`schema_validation.py`)
526
526
 
527
527
  `validate_json_schema(text, schema)` strips markdown code fences, parses JSON, and
528
- validates against the schema. Returns `None` on success, or an error description string
529
- on failure. Used internally by `AnthropicVertexProvider` for structured output retries.
528
+ validates against the schema. Returns `(error, cleaned_text)` tuple `error` is `None`
529
+ on success (and `cleaned_text` contains the fence-stripped JSON), or an error description
530
+ when validation fails. Used internally by `AnthropicVertexProvider` for structured output
531
+ retries. When validation succeeds, the provider returns a single `TextBlock` with the
532
+ cleaned JSON (no markdown fences, no thinking blocks).
530
533
 
531
534
  Internally, `strip_markdown_code_block(text)` removes surrounding markdown code fences
532
535
  (`` ```...``` ``) before parsing. This is an implementation detail, not part of the public API.