agentex-sdk 0.6.7__py3-none-any.whl → 0.7.1__py3-none-any.whl

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 (46) hide show
  1. agentex/_streaming.py +12 -10
  2. agentex/_types.py +3 -2
  3. agentex/_version.py +1 -1
  4. agentex/lib/core/temporal/plugins/claude_agents/__init__.py +72 -0
  5. agentex/lib/core/temporal/plugins/claude_agents/activities.py +154 -0
  6. agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py +11 -0
  7. agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py +212 -0
  8. agentex/lib/core/temporal/plugins/claude_agents/message_handler.py +178 -0
  9. agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +4 -2
  10. agentex/lib/environment_variables.py +6 -0
  11. agentex/lib/utils/completions.py +14 -0
  12. agentex/resources/agents.py +16 -0
  13. agentex/resources/messages/messages.py +163 -3
  14. agentex/resources/spans.py +8 -0
  15. agentex/resources/states.py +16 -0
  16. agentex/resources/tasks.py +8 -0
  17. agentex/resources/tracker.py +16 -0
  18. agentex/types/__init__.py +2 -0
  19. agentex/types/agent_list_params.py +6 -0
  20. agentex/types/agent_rpc_result.py +8 -0
  21. agentex/types/data_delta.py +2 -0
  22. agentex/types/message_list_paginated_params.py +19 -0
  23. agentex/types/message_list_paginated_response.py +21 -0
  24. agentex/types/message_list_params.py +5 -0
  25. agentex/types/reasoning_content_delta.py +2 -0
  26. agentex/types/reasoning_summary_delta.py +2 -0
  27. agentex/types/span_list_params.py +4 -0
  28. agentex/types/state.py +10 -0
  29. agentex/types/state_list_params.py +6 -0
  30. agentex/types/task_list_params.py +4 -0
  31. agentex/types/task_list_response.py +2 -0
  32. agentex/types/task_message.py +6 -0
  33. agentex/types/task_message_update.py +8 -0
  34. agentex/types/task_retrieve_by_name_response.py +2 -0
  35. agentex/types/task_retrieve_response.py +2 -0
  36. agentex/types/text_content.py +2 -0
  37. agentex/types/text_content_param.py +2 -0
  38. agentex/types/text_delta.py +2 -0
  39. agentex/types/tool_request_delta.py +2 -0
  40. agentex/types/tool_response_delta.py +2 -0
  41. agentex/types/tracker_list_params.py +6 -0
  42. {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/METADATA +5 -2
  43. {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/RECORD +46 -39
  44. {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/WHEEL +0 -0
  45. {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/entry_points.txt +0 -0
  46. {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/licenses/LICENSE +0 -0
agentex/_streaming.py CHANGED
@@ -54,11 +54,12 @@ class Stream(Generic[_T]):
54
54
  process_data = self._client._process_response_data
55
55
  iterator = self._iter_events()
56
56
 
57
- for sse in iterator:
58
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
59
-
60
- # As we might not fully consume the response stream, we need to close it explicitly
61
- response.close()
57
+ try:
58
+ for sse in iterator:
59
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
60
+ finally:
61
+ # Ensure the response is closed even if the consumer doesn't read all data
62
+ response.close()
62
63
 
63
64
  def __enter__(self) -> Self:
64
65
  return self
@@ -117,11 +118,12 @@ class AsyncStream(Generic[_T]):
117
118
  process_data = self._client._process_response_data
118
119
  iterator = self._iter_events()
119
120
 
120
- async for sse in iterator:
121
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
122
-
123
- # As we might not fully consume the response stream, we need to close it explicitly
124
- await response.aclose()
121
+ try:
122
+ async for sse in iterator:
123
+ yield process_data(data=sse.json(), cast_to=cast_to, response=response)
124
+ finally:
125
+ # Ensure the response is closed even if the consumer doesn't read all data
126
+ await response.aclose()
125
127
 
126
128
  async def __aenter__(self) -> Self:
127
129
  return self
agentex/_types.py CHANGED
@@ -243,6 +243,9 @@ _T_co = TypeVar("_T_co", covariant=True)
243
243
  if TYPE_CHECKING:
244
244
  # This works because str.__contains__ does not accept object (either in typeshed or at runtime)
245
245
  # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285
246
+ #
247
+ # Note: index() and count() methods are intentionally omitted to allow pyright to properly
248
+ # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr.
246
249
  class SequenceNotStr(Protocol[_T_co]):
247
250
  @overload
248
251
  def __getitem__(self, index: SupportsIndex, /) -> _T_co: ...
@@ -251,8 +254,6 @@ if TYPE_CHECKING:
251
254
  def __contains__(self, value: object, /) -> bool: ...
252
255
  def __len__(self) -> int: ...
253
256
  def __iter__(self) -> Iterator[_T_co]: ...
254
- def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ...
255
- def count(self, value: Any, /) -> int: ...
256
257
  def __reversed__(self) -> Iterator[_T_co]: ...
257
258
  else:
258
259
  # just point this to a normal `Sequence` at runtime to avoid having to special case
agentex/_version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
2
2
 
3
3
  __title__ = "agentex"
4
- __version__ = "0.6.7" # x-release-please-version
4
+ __version__ = "0.7.1" # x-release-please-version
@@ -0,0 +1,72 @@
1
+ """Claude Agents SDK integration with Temporal.
2
+
3
+ This plugin provides integration between Claude Agents SDK and AgentEx's
4
+ Temporal-based orchestration platform.
5
+
6
+ Features:
7
+ - Temporal activity wrapper for Claude SDK calls
8
+ - Real-time streaming to Redis/UI
9
+ - Session resume for conversation context
10
+ - Tool call visibility (Read, Write, Bash, etc.)
11
+ - Subagent support with nested tracing
12
+ - Workspace isolation per task
13
+
14
+ Architecture:
15
+ - activities.py: Temporal activity definitions
16
+ - message_handler.py: Message parsing and streaming logic
17
+ - Reuses OpenAI's ContextInterceptor for context threading
18
+
19
+ Usage:
20
+ from agentex.lib.core.temporal.plugins.claude_agents import (
21
+ run_claude_agent_activity,
22
+ create_workspace_directory,
23
+ ContextInterceptor,
24
+ )
25
+
26
+ # In worker
27
+ worker = AgentexWorker(
28
+ task_queue=queue_name,
29
+ interceptors=[ContextInterceptor()],
30
+ )
31
+
32
+ activities = get_all_activities()
33
+ activities.extend([run_claude_agent_activity, create_workspace_directory])
34
+
35
+ await worker.run(activities=activities, workflow=YourWorkflow)
36
+ """
37
+
38
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks import (
39
+ TemporalStreamingHooks,
40
+ create_streaming_hooks,
41
+ )
42
+ from agentex.lib.core.temporal.plugins.claude_agents.activities import (
43
+ run_claude_agent_activity,
44
+ create_workspace_directory,
45
+ )
46
+ from agentex.lib.core.temporal.plugins.claude_agents.message_handler import (
47
+ ClaudeMessageHandler,
48
+ )
49
+
50
+ # Reuse OpenAI's context threading - this is the key to streaming!
51
+ from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
52
+ ContextInterceptor,
53
+ streaming_task_id,
54
+ streaming_trace_id,
55
+ streaming_parent_span_id,
56
+ )
57
+
58
+ __all__ = [
59
+ # Activities
60
+ "run_claude_agent_activity",
61
+ "create_workspace_directory",
62
+ # Message handling
63
+ "ClaudeMessageHandler",
64
+ # Hooks
65
+ "create_streaming_hooks",
66
+ "TemporalStreamingHooks",
67
+ # Context threading (reused from OpenAI)
68
+ "ContextInterceptor",
69
+ "streaming_task_id",
70
+ "streaming_trace_id",
71
+ "streaming_parent_span_id",
72
+ ]
@@ -0,0 +1,154 @@
1
+ """Temporal activities for Claude Agents SDK integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from temporalio import activity
9
+ from claude_agent_sdk import AgentDefinition, ClaudeSDKClient, ClaudeAgentOptions
10
+
11
+ from agentex.lib.utils.logging import make_logger
12
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks import create_streaming_hooks
13
+ from agentex.lib.core.temporal.plugins.claude_agents.message_handler import ClaudeMessageHandler
14
+ from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import (
15
+ streaming_task_id,
16
+ streaming_trace_id,
17
+ streaming_parent_span_id,
18
+ )
19
+
20
+ logger = make_logger(__name__)
21
+
22
+
23
+ @activity.defn
24
+ async def create_workspace_directory(task_id: str, workspace_root: str | None = None) -> str:
25
+ """Create workspace directory for task - runs as Temporal activity
26
+
27
+ Args:
28
+ task_id: Task ID for workspace directory name
29
+ workspace_root: Root directory for workspaces (defaults to .claude-workspace/ in cwd)
30
+
31
+ Returns:
32
+ Absolute path to created workspace
33
+ """
34
+ if workspace_root is None:
35
+ # Default to .claude-workspace in current directory
36
+ # Follows Claude SDK's .claude/ convention
37
+ workspace_root = os.path.join(os.getcwd(), ".claude-workspace")
38
+
39
+ workspace_path = os.path.join(workspace_root, task_id)
40
+ os.makedirs(workspace_path, exist_ok=True)
41
+ logger.info(f"Created workspace: {workspace_path}")
42
+ return workspace_path
43
+
44
+
45
+ @activity.defn(name="run_claude_agent_activity")
46
+ async def run_claude_agent_activity(
47
+ prompt: str,
48
+ workspace_path: str,
49
+ allowed_tools: list[str],
50
+ permission_mode: str = "acceptEdits",
51
+ system_prompt: str | None = None,
52
+ resume_session_id: str | None = None,
53
+ agents: dict[str, Any] | None = None,
54
+ ) -> dict[str, Any]:
55
+ """Execute Claude SDK - wrapped in Temporal activity
56
+
57
+ This activity:
58
+ 1. Gets task_id from ContextVar (set by ContextInterceptor)
59
+ 2. Configures Claude with workspace isolation and session resume
60
+ 3. Runs Claude SDK and processes messages via ClaudeMessageHandler
61
+ 4. Streams messages to UI in real-time
62
+ 5. Returns session_id, usage, and cost for next turn
63
+
64
+ Args:
65
+ prompt: User message to send to Claude
66
+ workspace_path: Directory for file operations (cwd)
67
+ allowed_tools: List of tools Claude can use (include "Task" for subagents)
68
+ permission_mode: Permission mode (default: acceptEdits)
69
+ system_prompt: Optional system prompt override
70
+ resume_session_id: Optional session ID to resume conversation context
71
+ agents: Optional dict of subagent definitions for Task tool
72
+
73
+ Returns:
74
+ dict with "messages", "session_id", "usage", and "cost_usd" keys
75
+ """
76
+
77
+ # Get streaming context from ContextVars (set by interceptor)
78
+ task_id = streaming_task_id.get()
79
+ trace_id = streaming_trace_id.get()
80
+ parent_span_id = streaming_parent_span_id.get()
81
+
82
+ logger.info(
83
+ f"[run_claude_agent_activity] Starting - "
84
+ f"task_id={task_id}, workspace={workspace_path}, tools={allowed_tools}, "
85
+ f"resume={'YES' if resume_session_id else 'NO (new session)'}, "
86
+ f"subagents={list(agents.keys()) if agents else 'NONE'}"
87
+ )
88
+
89
+ # Reconstruct AgentDefinition objects from serialized dicts
90
+ # Temporal serializes dataclasses to dicts, need to recreate them
91
+ agent_defs = None
92
+ if agents:
93
+ agent_defs = {}
94
+ for name, agent_data in agents.items():
95
+ if isinstance(agent_data, AgentDefinition):
96
+ agent_defs[name] = agent_data
97
+ else:
98
+ # Reconstruct from dict
99
+ agent_defs[name] = AgentDefinition(
100
+ description=agent_data.get('description', ''),
101
+ prompt=agent_data.get('prompt', ''),
102
+ tools=agent_data.get('tools'),
103
+ model=agent_data.get('model'),
104
+ )
105
+
106
+ # Create hooks for streaming tool calls and subagent execution
107
+ hooks = create_streaming_hooks(
108
+ task_id=task_id,
109
+ trace_id=trace_id,
110
+ parent_span_id=parent_span_id,
111
+ )
112
+
113
+ # Configure Claude with workspace isolation, session resume, subagents, and hooks
114
+ options = ClaudeAgentOptions(
115
+ cwd=workspace_path,
116
+ allowed_tools=allowed_tools,
117
+ permission_mode=permission_mode, # type: ignore
118
+ system_prompt=system_prompt,
119
+ resume=resume_session_id,
120
+ agents=agent_defs,
121
+ hooks=hooks, # Tool lifecycle hooks for streaming!
122
+ )
123
+
124
+ # Create message handler for streaming
125
+ handler = ClaudeMessageHandler(
126
+ task_id=task_id,
127
+ trace_id=trace_id,
128
+ parent_span_id=parent_span_id,
129
+ )
130
+
131
+ # Run Claude and process messages
132
+ try:
133
+ await handler.initialize()
134
+
135
+ async with ClaudeSDKClient(options=options) as client:
136
+ await client.query(prompt)
137
+
138
+ # Use receive_response() instead of receive_messages()
139
+ # receive_response() yields messages until ResultMessage, then stops
140
+ # receive_messages() is infinite and never completes!
141
+ async for message in client.receive_response():
142
+ await handler.handle_message(message)
143
+
144
+ logger.debug(f"Message loop completed, cleaning up...")
145
+ await handler.cleanup()
146
+
147
+ results = handler.get_results()
148
+ logger.debug(f"Returning results with keys: {results.keys()}")
149
+ return results
150
+
151
+ except Exception as e:
152
+ logger.error(f"[run_claude_agent_activity] Error: {e}", exc_info=True)
153
+ await handler.cleanup()
154
+ raise
@@ -0,0 +1,11 @@
1
+ """Claude SDK hooks for streaming lifecycle events to AgentEx UI."""
2
+
3
+ from agentex.lib.core.temporal.plugins.claude_agents.hooks.hooks import (
4
+ TemporalStreamingHooks,
5
+ create_streaming_hooks,
6
+ )
7
+
8
+ __all__ = [
9
+ "create_streaming_hooks",
10
+ "TemporalStreamingHooks",
11
+ ]
@@ -0,0 +1,212 @@
1
+ """Claude SDK hooks for streaming tool calls and subagent execution to AgentEx UI.
2
+
3
+ This module provides hook callbacks that integrate with Claude SDK's hooks system
4
+ to stream tool execution lifecycle events in real-time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from claude_agent_sdk import HookMatcher
12
+
13
+ from agentex.lib import adk
14
+ from agentex.lib.utils.logging import make_logger
15
+ from agentex.types.task_message_update import StreamTaskMessageFull
16
+ from agentex.types.tool_request_content import ToolRequestContent
17
+ from agentex.types.tool_response_content import ToolResponseContent
18
+
19
+ logger = make_logger(__name__)
20
+
21
+
22
+ class TemporalStreamingHooks:
23
+ """Hooks for streaming Claude SDK lifecycle events to AgentEx UI.
24
+
25
+ Implements Claude SDK hook callbacks:
26
+ - PreToolUse: Called before tool execution → stream tool request
27
+ - PostToolUse: Called after tool execution → stream tool result
28
+
29
+ Also handles subagent detection and nested tracing.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ task_id: str | None,
35
+ trace_id: str | None = None,
36
+ parent_span_id: str | None = None,
37
+ ):
38
+ """Initialize streaming hooks.
39
+
40
+ Args:
41
+ task_id: AgentEx task ID for routing streams
42
+ trace_id: Trace ID for nested spans
43
+ parent_span_id: Parent span ID for subagent spans
44
+ """
45
+ self.task_id = task_id
46
+ self.trace_id = trace_id
47
+ self.parent_span_id = parent_span_id
48
+
49
+ # Track active subagent spans
50
+ self.subagent_spans: dict[str, Any] = {} # tool_call_id → (ctx, span)
51
+
52
+ async def pre_tool_use(
53
+ self,
54
+ input_data: dict[str, Any],
55
+ tool_use_id: str | None,
56
+ _context: Any,
57
+ ) -> dict[str, Any]:
58
+ """Hook called before tool execution.
59
+
60
+ Args:
61
+ input_data: Contains tool_name, tool_input from Claude SDK
62
+ tool_use_id: Unique ID for this tool call
63
+ context: Hook context from Claude SDK
64
+
65
+ Returns:
66
+ Empty dict (allow execution to proceed)
67
+ """
68
+ if not self.task_id or not tool_use_id:
69
+ return {}
70
+
71
+ tool_name = input_data.get("tool_name", "unknown")
72
+ tool_input = input_data.get("tool_input", {})
73
+
74
+ logger.info(f"🔧 Tool request: {tool_name}")
75
+
76
+ # Special handling for Task tool (subagents) - create nested span
77
+ if tool_name == "Task" and self.trace_id and self.parent_span_id:
78
+ subagent_type = tool_input.get("subagent_type", "unknown")
79
+ logger.info(f"🤖 Subagent started: {subagent_type}")
80
+
81
+ # Create nested trace span for subagent
82
+ subagent_ctx = adk.tracing.span(
83
+ trace_id=self.trace_id,
84
+ parent_id=self.parent_span_id,
85
+ name=f"Subagent: {subagent_type}",
86
+ input=tool_input,
87
+ )
88
+ subagent_span = await subagent_ctx.__aenter__()
89
+ self.subagent_spans[tool_use_id] = (subagent_ctx, subagent_span)
90
+
91
+ # Stream tool request to UI
92
+ try:
93
+ async with adk.streaming.streaming_task_message_context(
94
+ task_id=self.task_id,
95
+ initial_content=ToolRequestContent(
96
+ author="agent",
97
+ name=tool_name,
98
+ arguments=tool_input,
99
+ tool_call_id=tool_use_id,
100
+ )
101
+ ) as tool_ctx:
102
+ await tool_ctx.stream_update(
103
+ StreamTaskMessageFull(
104
+ parent_task_message=tool_ctx.task_message,
105
+ content=ToolRequestContent(
106
+ author="agent",
107
+ name=tool_name,
108
+ arguments=tool_input,
109
+ tool_call_id=tool_use_id,
110
+ ),
111
+ type="full"
112
+ )
113
+ )
114
+ except Exception as e:
115
+ logger.warning(f"Failed to stream tool request: {e}")
116
+
117
+ return {} # Allow execution
118
+
119
+ async def post_tool_use(
120
+ self,
121
+ input_data: dict[str, Any],
122
+ tool_use_id: str | None,
123
+ _context: Any,
124
+ ) -> dict[str, Any]:
125
+ """Hook called after tool execution.
126
+
127
+ Args:
128
+ input_data: Contains tool_name, tool_output from Claude SDK
129
+ tool_use_id: Unique ID for this tool call
130
+ context: Hook context from Claude SDK
131
+
132
+ Returns:
133
+ Empty dict
134
+ """
135
+ if not self.task_id or not tool_use_id:
136
+ return {}
137
+
138
+ tool_name = input_data.get("tool_name", "unknown")
139
+ tool_output = input_data.get("tool_output", "")
140
+
141
+ logger.info(f"✅ Tool result: {tool_name}")
142
+
143
+ # If this was a subagent, close the nested span
144
+ if tool_use_id in self.subagent_spans:
145
+ subagent_ctx, subagent_span = self.subagent_spans[tool_use_id]
146
+ subagent_span.output = {"result": tool_output}
147
+ await subagent_ctx.__aexit__(None, None, None)
148
+ logger.info(f"🤖 Subagent completed: {tool_name}")
149
+ del self.subagent_spans[tool_use_id]
150
+
151
+ # Stream tool response to UI
152
+ try:
153
+ async with adk.streaming.streaming_task_message_context(
154
+ task_id=self.task_id,
155
+ initial_content=ToolResponseContent(
156
+ author="agent",
157
+ name=tool_name,
158
+ content=tool_output,
159
+ tool_call_id=tool_use_id,
160
+ )
161
+ ) as tool_ctx:
162
+ await tool_ctx.stream_update(
163
+ StreamTaskMessageFull(
164
+ parent_task_message=tool_ctx.task_message,
165
+ content=ToolResponseContent(
166
+ author="agent",
167
+ name=tool_name,
168
+ content=tool_output,
169
+ tool_call_id=tool_use_id,
170
+ ),
171
+ type="full"
172
+ )
173
+ )
174
+ except Exception as e:
175
+ logger.warning(f"Failed to stream tool response: {e}")
176
+
177
+ return {}
178
+
179
+
180
+ def create_streaming_hooks(
181
+ task_id: str | None,
182
+ trace_id: str | None = None,
183
+ parent_span_id: str | None = None,
184
+ ) -> dict[str, list[HookMatcher]]:
185
+ """Create Claude SDK hooks configuration for streaming.
186
+
187
+ Returns hooks dict suitable for ClaudeAgentOptions(hooks=...).
188
+
189
+ Args:
190
+ task_id: AgentEx task ID for streaming
191
+ trace_id: Trace ID for nested spans
192
+ parent_span_id: Parent span ID for subagent spans
193
+
194
+ Returns:
195
+ Dict with PreToolUse and PostToolUse hook configurations
196
+ """
197
+ hooks_instance = TemporalStreamingHooks(task_id, trace_id, parent_span_id)
198
+
199
+ return {
200
+ "PreToolUse": [
201
+ HookMatcher(
202
+ matcher=None, # Match all tools
203
+ hooks=[hooks_instance.pre_tool_use]
204
+ )
205
+ ],
206
+ "PostToolUse": [
207
+ HookMatcher(
208
+ matcher=None, # Match all tools
209
+ hooks=[hooks_instance.post_tool_use]
210
+ )
211
+ ],
212
+ }
@@ -0,0 +1,178 @@
1
+ """Message handling and streaming for Claude Agents SDK.
2
+
3
+ Simplified message handler that focuses on:
4
+ - Streaming text content to UI
5
+ - Extracting session_id for conversation continuity
6
+ - Extracting usage and cost information
7
+
8
+ Tool requests/responses are handled by Claude SDK hooks (see hooks/hooks.py).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from claude_agent_sdk import (
16
+ TextBlock,
17
+ ResultMessage,
18
+ SystemMessage,
19
+ AssistantMessage,
20
+ )
21
+
22
+ from agentex.lib import adk
23
+ from agentex.lib.utils.logging import make_logger
24
+ from agentex.types.text_content import TextContent
25
+ from agentex.types.task_message_delta import TextDelta
26
+ from agentex.types.task_message_update import StreamTaskMessageDelta
27
+
28
+ logger = make_logger(__name__)
29
+
30
+
31
+ class ClaudeMessageHandler:
32
+ """Handles Claude SDK messages and streams them to AgentEx UI.
33
+
34
+ Simplified handler focused on:
35
+ - Streaming text blocks to UI
36
+ - Extracting session_id from SystemMessage/ResultMessage
37
+ - Extracting usage and cost from ResultMessage
38
+ - Serializing responses for Temporal
39
+
40
+ Note: Tool lifecycle events (requests/responses) are handled by
41
+ TemporalStreamingHooks, not this class.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ task_id: str | None,
47
+ trace_id: str | None,
48
+ parent_span_id: str | None,
49
+ ):
50
+ self.task_id = task_id
51
+ self.trace_id = trace_id
52
+ self.parent_span_id = parent_span_id
53
+
54
+ # Message tracking
55
+ self.messages: list[Any] = []
56
+ self.serialized_messages: list[dict] = []
57
+
58
+ # Streaming context for text
59
+ self.streaming_ctx = None
60
+
61
+ # Result data
62
+ self.session_id: str | None = None
63
+ self.usage_info: dict | None = None
64
+ self.cost_info: float | None = None
65
+
66
+ async def initialize(self):
67
+ """Initialize streaming context if task_id is available."""
68
+ if self.task_id:
69
+ logger.debug(f"Creating streaming context for task: {self.task_id}")
70
+ self.streaming_ctx = await adk.streaming.streaming_task_message_context(
71
+ task_id=self.task_id,
72
+ initial_content=TextContent(
73
+ author="agent",
74
+ content="",
75
+ format="markdown"
76
+ )
77
+ ).__aenter__()
78
+
79
+ async def handle_message(self, message: Any):
80
+ """Process a single message from Claude SDK."""
81
+ self.messages.append(message)
82
+ msg_num = len(self.messages)
83
+
84
+ # Debug logging (verbose - only for troubleshooting)
85
+ logger.debug(f"📨 [{msg_num}] Message type: {type(message).__name__}")
86
+ if isinstance(message, AssistantMessage):
87
+ block_types = [type(b).__name__ for b in message.content]
88
+ logger.debug(f" [{msg_num}] Content blocks: {block_types}")
89
+
90
+ # Route to specific handlers
91
+ # Note: Tool requests/responses are handled by hooks, not here!
92
+ if isinstance(message, AssistantMessage):
93
+ await self._handle_assistant_message(message, msg_num)
94
+ elif isinstance(message, SystemMessage):
95
+ await self._handle_system_message(message)
96
+ elif isinstance(message, ResultMessage):
97
+ await self._handle_result_message(message)
98
+
99
+ async def _handle_assistant_message(self, message: AssistantMessage, _msg_num: int):
100
+ """Handle AssistantMessage - contains text blocks.
101
+
102
+ Note: Tool calls (ToolUseBlock/ToolResultBlock) are handled by hooks, not here.
103
+ We only process TextBlock for streaming text to UI.
104
+ """
105
+ # Stream text blocks to UI
106
+ for block in message.content:
107
+ if isinstance(block, TextBlock):
108
+ await self._handle_text_block(block)
109
+
110
+ # Collect text for final response
111
+ text_content = []
112
+ for block in message.content:
113
+ if isinstance(block, TextBlock):
114
+ text_content.append(block.text)
115
+
116
+ if text_content:
117
+ self.serialized_messages.append({
118
+ "role": "assistant",
119
+ "content": "\n".join(text_content)
120
+ })
121
+
122
+ async def _handle_text_block(self, block: TextBlock):
123
+ """Handle text content block."""
124
+ if not block.text or not self.streaming_ctx:
125
+ return
126
+
127
+ logger.debug(f"💬 Text block: {block.text[:50]}...")
128
+
129
+ delta = TextDelta(type="text", text_delta=block.text)
130
+
131
+ try:
132
+ await self.streaming_ctx.stream_update(
133
+ StreamTaskMessageDelta(
134
+ parent_task_message=self.streaming_ctx.task_message,
135
+ delta=delta,
136
+ type="delta"
137
+ )
138
+ )
139
+ except Exception as e:
140
+ logger.warning(f"Failed to stream text delta: {e}")
141
+
142
+ async def _handle_system_message(self, message: SystemMessage):
143
+ """Handle system message - extract session_id."""
144
+ if message.subtype == "init":
145
+ self.session_id = message.data.get("session_id")
146
+ logger.debug(f"Session initialized: {self.session_id[:16] if self.session_id else 'unknown'}...")
147
+ else:
148
+ logger.debug(f"SystemMessage: {message.subtype}")
149
+
150
+ async def _handle_result_message(self, message: ResultMessage):
151
+ """Handle result message - extract usage and cost."""
152
+ self.usage_info = message.usage
153
+ self.cost_info = message.total_cost_usd
154
+
155
+ # Update session_id if available
156
+ if message.session_id:
157
+ self.session_id = message.session_id
158
+
159
+ logger.info(f"💰 Cost: ${self.cost_info:.4f}, Duration: {message.duration_ms}ms, Turns: {message.num_turns}")
160
+
161
+ async def cleanup(self):
162
+ """Clean up open streaming contexts."""
163
+ if self.streaming_ctx:
164
+ try:
165
+ await self.streaming_ctx.close()
166
+ logger.debug(f"Closed streaming context")
167
+ except Exception as e:
168
+ logger.warning(f"Failed to close streaming context: {e}")
169
+
170
+ def get_results(self) -> dict[str, Any]:
171
+ """Get final results for Temporal."""
172
+ return {
173
+ "messages": self.serialized_messages,
174
+ "task_id": self.task_id,
175
+ "session_id": self.session_id,
176
+ "usage": self.usage_info,
177
+ "cost_usd": self.cost_info,
178
+ }