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.
- {flowra-0.0.2.dev5 → flowra-0.0.3}/CHANGELOG.md +11 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/PKG-INFO +1 -1
- {flowra-0.0.2.dev5 → flowra-0.0.3}/context7.json +6 -6
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/architecture.md +2 -1
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/lib.md +85 -131
- flowra-0.0.3/docs/research/hooks_redesign.md +273 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/research/strands_comparison.md +4 -4
- flowra-0.0.3/docs/research/voice_stt.md +105 -0
- flowra-0.0.3/docs/todo.md +126 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/console_chat.py +9 -14
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/llm_logging.py +5 -4
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tui_chat.py +48 -44
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/__init__.py +5 -0
- flowra-0.0.3/flowra/agent/hooks.py +113 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/__init__.py +2 -4
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/agent.py +8 -11
- flowra-0.0.3/flowra/lib/chat/hook_events.py +23 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/spec.py +1 -1
- flowra-0.0.3/flowra/lib/tool_loop/__init__.py +71 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/agent.py +50 -56
- flowra-0.0.3/flowra/lib/tool_loop/hook_events.py +178 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/spec.py +1 -1
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/version.py +1 -1
- flowra-0.0.3/tests/agent/test_hooks.py +167 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_chat_agent.py +45 -34
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_tool_loop_agent.py +127 -125
- flowra-0.0.2.dev5/docs/todo.md +0 -22
- flowra-0.0.2.dev5/flowra/lib/chat/hook_executor.py +0 -21
- flowra-0.0.2.dev5/flowra/lib/chat/hooks.py +0 -43
- flowra-0.0.2.dev5/flowra/lib/tool_loop/__init__.py +0 -107
- flowra-0.0.2.dev5/flowra/lib/tool_loop/hook_executor.py +0 -221
- flowra-0.0.2.dev5/flowra/lib/tool_loop/hooks.py +0 -479
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.claude/commands/update-pricing.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.env.example +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/master.yml +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/publish.yml +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/pull_request.yml +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.github/workflows/pull_request_e2e.yml +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.gitignore +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/.python-version +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/CLAUDE.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/LICENSE +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/Makefile +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/README.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/agent.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/llm.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_plan.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step1_structure.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step2_code_style.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step3_documentation.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step4_doc_readability.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step5_doc_audit.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/review_prompts/step6_tests.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/runtime.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/docs/tools.md +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/app_agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/llm_routing.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/menu_agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/menu_agent_class.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/model_registry.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/system_prompt.txt +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/calculator.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/random_numbers.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/examples/tools/switch_model.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_def.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_registry.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/agent_store.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/compile.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/interrupt_helpers.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/interrupt_token.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/service_locator.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/step_decorator.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/agent/stored_values.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/chat/config.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/config_value.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/llm_config.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/_tool_call_agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/cache.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/config.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/lib/tool_loop/context.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/_base.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/blocks.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/messages.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/anthropic.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/google.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/pricing/openai.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/provider.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/anthropic_vertex.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/google_vertex.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/providers/openai.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/request.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/response.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/schema_formatting.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/schema_validation.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/stream.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/llm/tools.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/py.typed +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/_sealed_scope.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/engine.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/execution.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/interrupt.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/runtime.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/runtime_scope.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/serialization.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/file.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/in_memory.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/runtime/storage/session_storage.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/local_tool.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/mcp_connection.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/tool_group.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/tool_registry.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/flowra/tools/types.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/pyproject.toml +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent_def.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_agent_registry.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_compile.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_step_ref.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_values.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/agent/test_with_interrupt.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_config_value.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/test_tool_call_agent.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/tool_loop/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/lib/tool_loop/test_cache.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_anthropic.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_google.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/pricing/test_openai.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_anthropic_e2e.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_anthropic_vertex.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_google_vertex.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_google_vertex_e2e.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_openai_e2e.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/providers/test_openai_provider.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_metadata.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_response.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_schema_formatting.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_schema_validation.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/llm/test_stream.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/test_file.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/storage/test_in_memory.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_engine.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_interrupt.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_persistence.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_runtime.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_scope.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/runtime/test_serialization.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/__init__.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_local_tool.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_mcp_connection.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_tool_group.py +0 -0
- {flowra-0.0.2.dev5 → flowra-0.0.3}/tests/tools/test_tool_registry.py +0 -0
- {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.
|
|
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
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"Transient message pattern: mark messages with metadata={'transient': True}, then filter in
|
|
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
|
-
"
|
|
73
|
-
"When
|
|
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,
|
|
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
|
|
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
|
-
│ ├──
|
|
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
|
-
├──
|
|
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
|
-
| `
|
|
115
|
+
| `metadata` | `dict[str, Any] \| None` | `None` |
|
|
118
116
|
|
|
119
|
-
`
|
|
120
|
-
|
|
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
|
-
###
|
|
152
|
+
### Chat hooks
|
|
155
153
|
|
|
156
|
-
Chat-level
|
|
154
|
+
Chat-level hook events, registered on the same `HookRegistry` instance as tool loop hooks.
|
|
157
155
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
from long-term session history.
|
|
160
|
+
| Field | Type | Default |
|
|
161
|
+
|---------------------|---------------------------|---------|
|
|
162
|
+
| `messages` | `list[Message]` | — |
|
|
166
163
|
|
|
167
164
|
```python
|
|
168
|
-
from flowra.
|
|
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(
|
|
172
|
-
|
|
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 =
|
|
172
|
+
hooks = HookRegistry()
|
|
173
|
+
hooks.on(SaveTurnMessagesEvent, filter_transient)
|
|
175
174
|
|
|
176
175
|
runtime = AgentRuntime(
|
|
177
176
|
agents={"chat": ChatAgent},
|
|
178
|
-
services={..., ChatConfig: config,
|
|
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
|
-
| `
|
|
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
|
-
|
|
282
|
-
|
|
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.
|
|
285
|
+
from flowra.agent import HookRegistry
|
|
286
|
+
from flowra.lib.tool_loop import BeforeLLMCallEvent, AfterLLMCallEvent
|
|
286
287
|
|
|
287
|
-
hooks =
|
|
288
|
-
|
|
289
|
-
|
|
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={...,
|
|
294
|
+
services={..., HookRegistry: hooks},
|
|
295
295
|
)
|
|
296
296
|
```
|
|
297
297
|
|
|
298
|
-
### `
|
|
299
|
-
|
|
300
|
-
|
|
|
301
|
-
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
6. **`
|
|
341
|
-
|
|
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
|
-
|
|
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,
|
|
558
|
-
`turn_messages`, flushes,
|
|
559
|
-
2. `call_llm` — checks interrupt/finish/max_iterations,
|
|
560
|
-
builds `LLMRequest`,
|
|
561
|
-
`
|
|
562
|
-
`
|
|
563
|
-
- `END_TURN` →
|
|
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` →
|
|
526
|
+
- `TOOL_USE` → emits `BeforeToolCallEvent` for each tool, spawns `ToolCallAgent`
|
|
566
527
|
children in parallel
|
|
567
|
-
3. `collect_results` — gathers results from `ToolCallAgent` children,
|
|
568
|
-
`
|
|
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.
|