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.
- agentex/_streaming.py +12 -10
- agentex/_types.py +3 -2
- agentex/_version.py +1 -1
- agentex/lib/core/temporal/plugins/claude_agents/__init__.py +72 -0
- agentex/lib/core/temporal/plugins/claude_agents/activities.py +154 -0
- agentex/lib/core/temporal/plugins/claude_agents/hooks/__init__.py +11 -0
- agentex/lib/core/temporal/plugins/claude_agents/hooks/hooks.py +212 -0
- agentex/lib/core/temporal/plugins/claude_agents/message_handler.py +178 -0
- agentex/lib/core/temporal/plugins/openai_agents/interceptors/context_interceptor.py +4 -2
- agentex/lib/environment_variables.py +6 -0
- agentex/lib/utils/completions.py +14 -0
- agentex/resources/agents.py +16 -0
- agentex/resources/messages/messages.py +163 -3
- agentex/resources/spans.py +8 -0
- agentex/resources/states.py +16 -0
- agentex/resources/tasks.py +8 -0
- agentex/resources/tracker.py +16 -0
- agentex/types/__init__.py +2 -0
- agentex/types/agent_list_params.py +6 -0
- agentex/types/agent_rpc_result.py +8 -0
- agentex/types/data_delta.py +2 -0
- agentex/types/message_list_paginated_params.py +19 -0
- agentex/types/message_list_paginated_response.py +21 -0
- agentex/types/message_list_params.py +5 -0
- agentex/types/reasoning_content_delta.py +2 -0
- agentex/types/reasoning_summary_delta.py +2 -0
- agentex/types/span_list_params.py +4 -0
- agentex/types/state.py +10 -0
- agentex/types/state_list_params.py +6 -0
- agentex/types/task_list_params.py +4 -0
- agentex/types/task_list_response.py +2 -0
- agentex/types/task_message.py +6 -0
- agentex/types/task_message_update.py +8 -0
- agentex/types/task_retrieve_by_name_response.py +2 -0
- agentex/types/task_retrieve_response.py +2 -0
- agentex/types/text_content.py +2 -0
- agentex/types/text_content_param.py +2 -0
- agentex/types/text_delta.py +2 -0
- agentex/types/tool_request_delta.py +2 -0
- agentex/types/tool_response_delta.py +2 -0
- agentex/types/tracker_list_params.py +6 -0
- {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/METADATA +5 -2
- {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/RECORD +46 -39
- {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/WHEEL +0 -0
- {agentex_sdk-0.6.7.dist-info → agentex_sdk-0.7.1.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
@@ -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
|
+
}
|