|
| 1 | +"""Temporal streaming hooks for OpenAI Agents SDK lifecycle events. |
| 2 | +
|
| 3 | +This module provides a convenience class for streaming agent lifecycle events |
| 4 | +to the AgentEx UI via Temporal activities. |
| 5 | +""" |
| 6 | + |
| 7 | +from typing import Any |
| 8 | +from datetime import timedelta |
| 9 | + |
| 10 | +from agents import Agent, RunContextWrapper, RunHooks, Tool |
| 11 | +from agents.tool_context import ToolContext |
| 12 | +from temporalio import workflow |
| 13 | + |
| 14 | +from agentex.types.text_content import TextContent |
| 15 | +from agentex.types.task_message_content import ToolRequestContent, ToolResponseContent |
| 16 | +from agentex.lib.core.temporal.plugins.openai_agents.activities import stream_lifecycle_content |
| 17 | + |
| 18 | + |
| 19 | +class TemporalStreamingHooks(RunHooks): |
| 20 | + """Convenience hooks class for streaming OpenAI Agent lifecycle events to the AgentEx UI. |
| 21 | +
|
| 22 | + This class automatically streams agent lifecycle events (tool calls, handoffs) to the |
| 23 | + AgentEx UI via Temporal activities. It subclasses the OpenAI Agents SDK's RunHooks |
| 24 | + to intercept lifecycle events and forward them for real-time UI updates. |
| 25 | +
|
| 26 | + Lifecycle events streamed: |
| 27 | + - Tool requests (on_tool_start): Streams when a tool is about to be invoked |
| 28 | + - Tool responses (on_tool_end): Streams the tool's execution result |
| 29 | + - Agent handoffs (on_handoff): Streams when control transfers between agents |
| 30 | +
|
| 31 | + Usage: |
| 32 | + Basic usage - streams all lifecycle events:: |
| 33 | +
|
| 34 | + from agentex.lib.core.temporal.plugins.openai_agents import TemporalStreamingHooks |
| 35 | +
|
| 36 | + hooks = TemporalStreamingHooks(task_id="abc123") |
| 37 | + result = await Runner.run(agent, input, hooks=hooks) |
| 38 | +
|
| 39 | + Advanced - subclass for custom behavior:: |
| 40 | +
|
| 41 | + class MyCustomHooks(TemporalStreamingHooks): |
| 42 | + async def on_tool_start(self, context, agent, tool): |
| 43 | + # Add custom logic before streaming |
| 44 | + await self.my_custom_logging(tool) |
| 45 | + # Call parent to stream to UI |
| 46 | + await super().on_tool_start(context, agent, tool) |
| 47 | +
|
| 48 | + async def on_agent_start(self, context, agent): |
| 49 | + # Override empty methods for additional tracking |
| 50 | + print(f"Agent {agent.name} started") |
| 51 | +
|
| 52 | + Power users can ignore this class and subclass agents.RunHooks directly for full control. |
| 53 | +
|
| 54 | + Note: |
| 55 | + Tool arguments are not available in hooks due to OpenAI SDK architecture. |
| 56 | + The SDK's hook signature doesn't include tool arguments - they're only passed |
| 57 | + to the actual tool function. This is why arguments={} in ToolRequestContent. |
| 58 | +
|
| 59 | + Attributes: |
| 60 | + task_id: The AgentEx task ID for routing streamed events |
| 61 | + timeout: Timeout for streaming activity calls (default: 10 seconds) |
| 62 | + """ |
| 63 | + |
| 64 | + def __init__( |
| 65 | + self, |
| 66 | + task_id: str, |
| 67 | + timeout: timedelta = timedelta(seconds=10), |
| 68 | + ): |
| 69 | + """Initialize the streaming hooks. |
| 70 | +
|
| 71 | + Args: |
| 72 | + task_id: AgentEx task ID for routing streamed events to the correct UI session |
| 73 | + timeout: Timeout for streaming activity invocations (default: 10 seconds) |
| 74 | + """ |
| 75 | + super().__init__() |
| 76 | + self.task_id = task_id |
| 77 | + self.timeout = timeout |
| 78 | + |
| 79 | + async def on_agent_start(self, context: RunContextWrapper, agent: Agent) -> None: |
| 80 | + """Called when an agent starts execution. |
| 81 | +
|
| 82 | + Default implementation does nothing. Override to add custom behavior. |
| 83 | +
|
| 84 | + Args: |
| 85 | + context: The run context wrapper |
| 86 | + agent: The agent that is starting |
| 87 | + """ |
| 88 | + pass |
| 89 | + |
| 90 | + async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: |
| 91 | + """Called when an agent completes execution. |
| 92 | +
|
| 93 | + Default implementation does nothing. Override to add custom behavior. |
| 94 | +
|
| 95 | + Args: |
| 96 | + context: The run context wrapper |
| 97 | + agent: The agent that completed |
| 98 | + output: The agent's output |
| 99 | + """ |
| 100 | + pass |
| 101 | + |
| 102 | + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: |
| 103 | + """Stream tool request when a tool starts execution. |
| 104 | +
|
| 105 | + Extracts the tool_call_id from the context and streams a ToolRequestContent |
| 106 | + message to the UI showing that the tool is about to execute. |
| 107 | +
|
| 108 | + Note: Tool arguments are not available in the hook context due to OpenAI SDK |
| 109 | + design. The hook signature doesn't include tool arguments - they're passed |
| 110 | + directly to the tool function instead. We send an empty dict as a placeholder. |
| 111 | +
|
| 112 | + Args: |
| 113 | + context: The run context wrapper (will be a ToolContext with tool_call_id) |
| 114 | + agent: The agent executing the tool |
| 115 | + tool: The tool being executed |
| 116 | + """ |
| 117 | + tool_context = context if isinstance(context, ToolContext) else None |
| 118 | + tool_call_id = tool_context.tool_call_id if tool_context else f"call_{id(tool)}" |
| 119 | + |
| 120 | + await workflow.execute_activity_method( |
| 121 | + stream_lifecycle_content, |
| 122 | + args=[ |
| 123 | + self.task_id, |
| 124 | + ToolRequestContent( |
| 125 | + author="agent", |
| 126 | + tool_call_id=tool_call_id, |
| 127 | + name=tool.name, |
| 128 | + arguments={}, # Not available in hook context - SDK limitation |
| 129 | + ), |
| 130 | + ], |
| 131 | + start_to_close_timeout=self.timeout, |
| 132 | + ) |
| 133 | + |
| 134 | + async def on_tool_end( |
| 135 | + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str |
| 136 | + ) -> None: |
| 137 | + """Stream tool response when a tool completes execution. |
| 138 | +
|
| 139 | + Extracts the tool_call_id and streams a ToolResponseContent message to the UI |
| 140 | + showing the tool's execution result. |
| 141 | +
|
| 142 | + Args: |
| 143 | + context: The run context wrapper (will be a ToolContext with tool_call_id) |
| 144 | + agent: The agent that executed the tool |
| 145 | + tool: The tool that was executed |
| 146 | + result: The tool's execution result |
| 147 | + """ |
| 148 | + tool_context = context if isinstance(context, ToolContext) else None |
| 149 | + tool_call_id = ( |
| 150 | + getattr(tool_context, "tool_call_id", f"call_{id(tool)}") |
| 151 | + if tool_context |
| 152 | + else f"call_{id(tool)}" |
| 153 | + ) |
| 154 | + |
| 155 | + await workflow.execute_activity_method( |
| 156 | + stream_lifecycle_content, |
| 157 | + args=[ |
| 158 | + self.task_id, |
| 159 | + ToolResponseContent( |
| 160 | + author="agent", |
| 161 | + tool_call_id=tool_call_id, |
| 162 | + name=tool.name, |
| 163 | + content=result, |
| 164 | + ), |
| 165 | + ], |
| 166 | + start_to_close_timeout=self.timeout, |
| 167 | + ) |
| 168 | + |
| 169 | + async def on_handoff( |
| 170 | + self, context: RunContextWrapper, from_agent: Agent, to_agent: Agent |
| 171 | + ) -> None: |
| 172 | + """Stream handoff message when control transfers between agents. |
| 173 | +
|
| 174 | + Sends a text message to the UI indicating that one agent is handing off |
| 175 | + to another agent. |
| 176 | +
|
| 177 | + Args: |
| 178 | + context: The run context wrapper |
| 179 | + from_agent: The agent transferring control |
| 180 | + to_agent: The agent receiving control |
| 181 | + """ |
| 182 | + await workflow.execute_activity_method( |
| 183 | + stream_lifecycle_content, |
| 184 | + args=[ |
| 185 | + self.task_id, |
| 186 | + TextContent( |
| 187 | + author="agent", |
| 188 | + content=f"Handoff from {from_agent.name} to {to_agent.name}", |
| 189 | + type="text", |
| 190 | + ), |
| 191 | + ], |
| 192 | + start_to_close_timeout=self.timeout, |
| 193 | + ) |
0 commit comments