33from __future__ import annotations
44
55import os
6+ import dataclasses
67from typing import Any
78
89from temporalio import activity
1920
2021logger = make_logger (__name__ )
2122
23+ # Fields that are not serializable across the Temporal boundary and should be
24+ # excluded from claude_options_to_dict output.
25+ _NON_SERIALIZABLE_FIELDS = {"debug_stderr" , "stderr" , "can_use_tool" , "hooks" }
26+
27+
28+ def claude_options_to_dict (options : ClaudeAgentOptions ) -> dict [str , Any ]:
29+ """Convert a ClaudeAgentOptions to a Temporal-serializable dict.
30+
31+ Use this at the workflow call site so you get full type safety and
32+ autocomplete when constructing options, while Temporal gets a plain dict.
33+
34+ Non-serializable fields (callbacks, file objects, hooks) are excluded —
35+ the activity injects AgentEx streaming hooks automatically.
36+
37+ Example::
38+
39+ extra = ClaudeAgentOptions(
40+ mcp_servers={"my-server": McpServerConfig(command="npx", args=[...])},
41+ model="sonnet",
42+ )
43+
44+ result = await workflow.execute_activity(
45+ run_claude_agent_activity,
46+ args=[prompt, workspace, tools, "acceptEdits", None, None, None,
47+ claude_options_to_dict(extra)],
48+ ...
49+ )
50+ """
51+ result = {}
52+ for field in dataclasses .fields (options ):
53+ if field .name in _NON_SERIALIZABLE_FIELDS :
54+ continue
55+ value = getattr (options , field .name )
56+ # Skip fields left at their default to keep the dict minimal
57+ if value == field .default or (
58+ callable (field .default_factory ) and value == field .default_factory () # type: ignore[arg-type]
59+ ):
60+ continue
61+ result [field .name ] = value
62+ return result
63+
64+
65+ def _reconstruct_agent_defs (agents : dict [str , Any ] | None ) -> dict [str , AgentDefinition ] | None :
66+ """Reconstruct AgentDefinition objects from Temporal-serialized dicts."""
67+ if not agents :
68+ return None
69+ agent_defs = {}
70+ for name , agent_data in agents .items ():
71+ if isinstance (agent_data , AgentDefinition ):
72+ agent_defs [name ] = agent_data
73+ else :
74+ agent_defs [name ] = AgentDefinition (
75+ description = agent_data .get ('description' , '' ),
76+ prompt = agent_data .get ('prompt' , '' ),
77+ tools = agent_data .get ('tools' ),
78+ model = agent_data .get ('model' ),
79+ )
80+ return agent_defs
81+
2282
2383@activity .defn
2484async def create_workspace_directory (task_id : str , workspace_root : str | None = None ) -> str :
@@ -51,8 +111,9 @@ async def run_claude_agent_activity(
51111 system_prompt : str | None = None ,
52112 resume_session_id : str | None = None ,
53113 agents : dict [str , Any ] | None = None ,
114+ claude_options : dict [str , Any ] | None = None ,
54115) -> dict [str , Any ]:
55- """Execute Claude SDK - wrapped in Temporal activity
116+ """Execute Claude SDK - wrapped in Temporal activity.
56117
57118 This activity:
58119 1. Gets task_id from ContextVar (set by ContextInterceptor)
@@ -69,6 +130,11 @@ async def run_claude_agent_activity(
69130 system_prompt: Optional system prompt override
70131 resume_session_id: Optional session ID to resume conversation context
71132 agents: Optional dict of subagent definitions for Task tool
133+ claude_options: Optional dict of additional ClaudeAgentOptions kwargs.
134+ Any field supported by the Claude SDK can be passed here
135+ (e.g. mcp_servers, model, max_turns, max_budget_usd, etc.).
136+ These are merged with the explicit params above, with explicit
137+ params taking precedence.
72138
73139 Returns:
74140 dict with "messages", "session_id", "usage", and "cost_usd" keys
@@ -88,38 +154,49 @@ async def run_claude_agent_activity(
88154
89155 # Reconstruct AgentDefinition objects from serialized dicts
90156 # 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- )
157+ agent_defs = _reconstruct_agent_defs (agents )
158+
159+ # Build options dict from explicit params
160+ options_dict : dict [str , Any ] = {
161+ "cwd" : workspace_path ,
162+ "allowed_tools" : allowed_tools ,
163+ "permission_mode" : permission_mode ,
164+ "system_prompt" : system_prompt ,
165+ "resume" : resume_session_id ,
166+ "agents" : agent_defs ,
167+ }
168+
169+ # Merge in any additional claude_options (explicit params take precedence)
170+ if claude_options :
171+ # Reconstruct agents in claude_options too if present
172+ if "agents" in claude_options :
173+ claude_options ["agents" ] = _reconstruct_agent_defs (claude_options ["agents" ])
174+ merged = {** claude_options , ** options_dict }
175+ # Remove None values from explicit params so claude_options defaults aren't masked
176+ options_dict = {k : v for k , v in merged .items () if v is not None }
105177
106178 # Create hooks for streaming tool calls and subagent execution
107- hooks = create_streaming_hooks (
179+ streaming_hooks = create_streaming_hooks (
108180 task_id = task_id ,
109181 trace_id = trace_id ,
110182 parent_span_id = parent_span_id ,
111183 )
112184
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- )
185+ # Merge streaming hooks with any user-provided hooks from claude_options
186+ user_hooks = options_dict .pop ("hooks" , None )
187+ if user_hooks :
188+ merged_hooks = dict (streaming_hooks )
189+ for event , matchers in user_hooks .items ():
190+ if event in merged_hooks :
191+ merged_hooks [event ] = merged_hooks [event ] + matchers
192+ else :
193+ merged_hooks [event ] = matchers
194+ options_dict ["hooks" ] = merged_hooks
195+ else :
196+ options_dict ["hooks" ] = streaming_hooks
197+
198+ # Construct ClaudeAgentOptions — any SDK field works via claude_options
199+ options = ClaudeAgentOptions (** options_dict )
123200
124201 # Create message handler for streaming
125202 handler = ClaudeMessageHandler (
0 commit comments