1616from datetime import datetime , timezone
1717from typing import Any , Generic , TypeVar
1818
19- from agent_framework import AgentRunResponse , AgentThread , ChatMessage , ErrorContent , Role , get_logger
19+ from agent_framework import AgentRunResponse , AgentThread , ChatMessage , ErrorContent , Role , TextContent , get_logger
2020from durabletask .client import TaskHubGrpcClient
2121from durabletask .entities import EntityInstanceId
22- from durabletask .task import CompositeTask , OrchestrationContext , Task
22+ from durabletask .task import CompletableTask , CompositeTask , OrchestrationContext , Task
2323from pydantic import BaseModel
2424
2525from ._constants import DEFAULT_MAX_POLL_RETRIES , DEFAULT_POLL_INTERVAL_SECONDS
3333TaskT = TypeVar ("TaskT" )
3434
3535
36- class DurableAgentTask (CompositeTask [AgentRunResponse ]):
36+ class DurableAgentTask (CompositeTask [AgentRunResponse ], CompletableTask [ AgentRunResponse ] ):
3737 """A custom Task that wraps entity calls and provides typed AgentRunResponse results.
3838
3939 This task wraps the underlying entity call task and intercepts its completion
4040 to convert the raw result into a typed AgentRunResponse object.
41+
42+ When yielded in an orchestration, this task returns an AgentRunResponse:
43+ response: AgentRunResponse = yield durable_agent_task
4144 """
4245
4346 def __init__ (
4447 self ,
45- entity_task : Task [Any ],
48+ entity_task : CompletableTask [Any ],
4649 response_format : type [BaseModel ] | None ,
4750 correlation_id : str ,
4851 ):
@@ -55,7 +58,7 @@ def __init__(
5558 """
5659 self ._response_format = response_format
5760 self ._correlation_id = correlation_id
58- super ().__init__ ([entity_task ]) # type: ignore[misc]
61+ super ().__init__ ([entity_task ]) # type: ignore
5962
6063 def on_child_completed (self , task : Task [Any ]) -> None :
6164 """Handle completion of the underlying entity task.
@@ -69,11 +72,8 @@ def on_child_completed(self, task: Task[Any]) -> None:
6972 return
7073
7174 if task .is_failed :
72- # Propagate the failure
73- self ._exception = task .get_exception ()
74- self ._is_complete = True
75- if self ._parent is not None :
76- self ._parent .on_child_completed (self )
75+ # Propagate the failure - pass the original exception directly
76+ self .fail ("call_entity Task failed" , task .get_exception ())
7777 return
7878
7979 # Task succeeded - transform the raw result
@@ -94,18 +94,12 @@ def on_child_completed(self, task: Task[Any]) -> None:
9494 )
9595
9696 # Set the typed AgentRunResponse as this task's result
97- self ._result = response
98- self ._is_complete = True
99-
100- if self ._parent is not None :
101- self ._parent .on_child_completed (self )
97+ self .complete (response )
10298
103- except Exception :
104- logger .exception (
105- "[DurableAgentTask] Failed to convert result for correlation_id: %s" ,
106- self ._correlation_id ,
107- )
108- raise
99+ except Exception as ex :
100+ err_msg = "[DurableAgentTask] Failed to convert result for correlation_id: " + self ._correlation_id
101+ logger .exception (err_msg )
102+ self .fail (err_msg , ex )
109103
110104
111105class DurableAgentExecutor (ABC , Generic [TaskT ]):
@@ -155,16 +149,42 @@ def get_run_request(
155149 message : str ,
156150 response_format : type [BaseModel ] | None ,
157151 enable_tool_calls : bool ,
152+ wait_for_response : bool = True ,
158153 ) -> RunRequest :
159154 """Create a RunRequest for the given parameters."""
160155 correlation_id = self .generate_unique_id ()
161156 return RunRequest (
162157 message = message ,
163158 response_format = response_format ,
164159 enable_tool_calls = enable_tool_calls ,
160+ wait_for_response = wait_for_response ,
165161 correlation_id = correlation_id ,
166162 )
167163
164+ def _create_acceptance_response (self , correlation_id : str ) -> AgentRunResponse :
165+ """Create an acceptance response for fire-and-forget mode.
166+
167+ Args:
168+ correlation_id: Correlation ID for tracking the request
169+
170+ Returns:
171+ AgentRunResponse: Acceptance response with correlation ID
172+ """
173+ acceptance_message = ChatMessage (
174+ role = Role .SYSTEM ,
175+ contents = [
176+ TextContent (
177+ f"Request accepted for processing (correlation_id: { correlation_id } ). "
178+ f"Agent is executing in the background. "
179+ f"Retrieve response via your configured streaming or callback mechanism."
180+ )
181+ ],
182+ )
183+ return AgentRunResponse (
184+ messages = [acceptance_message ],
185+ created_at = datetime .now (timezone .utc ).isoformat (),
186+ )
187+
168188
169189class ClientAgentExecutor (DurableAgentExecutor [AgentRunResponse ]):
170190 """Execution strategy for external clients.
@@ -205,11 +225,20 @@ def run_durable_agent(
205225 thread: Optional conversation thread (creates new if not provided)
206226
207227 Returns:
208- AgentRunResponse: The agent's response after execution completes
228+ AgentRunResponse: The agent's response after execution completes, or an immediate
229+ acknowledgement if wait_for_response is False
209230 """
210231 # Signal the entity with the request
211232 entity_id = self ._signal_agent_entity (agent_name , run_request , thread )
212233
234+ # If fire-and-forget mode, return immediately without polling
235+ if not run_request .wait_for_response :
236+ logger .info (
237+ "[ClientAgentExecutor] Fire-and-forget mode: request signaled (correlation: %s)" ,
238+ run_request .correlation_id ,
239+ )
240+ return self ._create_acceptance_response (run_request .correlation_id )
241+
213242 # Poll for the response
214243 agent_response = self ._poll_for_agent_response (entity_id , run_request .correlation_id )
215244
@@ -395,11 +424,16 @@ def __init__(self, context: OrchestrationContext):
395424 self ._context = context
396425 logger .debug ("[OrchestrationAgentExecutor] Initialized" )
397426
427+ def generate_unique_id (self ) -> str :
428+ """Create a new UUID that is safe for replay within an orchestration or operation."""
429+ return self ._context .new_uuid ()
430+
398431 def get_run_request (
399432 self ,
400433 message : str ,
401434 response_format : type [BaseModel ] | None ,
402435 enable_tool_calls : bool ,
436+ wait_for_response : bool = True ,
403437 ) -> RunRequest :
404438 """Get the current run request from the orchestration context.
405439
@@ -410,6 +444,7 @@ def get_run_request(
410444 message ,
411445 response_format ,
412446 enable_tool_calls ,
447+ wait_for_response ,
413448 )
414449 request .orchestration_id = self ._context .instance_id
415450 return request
@@ -449,8 +484,22 @@ def run_durable_agent(
449484 session_id ,
450485 )
451486
452- # Call the entity and get the underlying task
453- entity_task : Task [Any ] = self ._context .call_entity (entity_id , "run" , run_request .to_dict ()) # type: ignore
487+ # Branch based on wait_for_response
488+ if not run_request .wait_for_response :
489+ # Fire-and-forget mode: signal entity and return pre-completed task
490+ logger .info (
491+ "[OrchestrationAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)" ,
492+ run_request .correlation_id ,
493+ )
494+ self ._context .signal_entity (entity_id , "run" , run_request .to_dict ())
495+
496+ # Create a pre-completed task with acceptance response
497+ acceptance_response = self ._create_acceptance_response (run_request .correlation_id )
498+ entity_task : CompletableTask [AgentRunResponse ] = CompletableTask ()
499+ entity_task .complete (acceptance_response )
500+ else :
501+ # Blocking mode: call entity and wait for response
502+ entity_task = self ._context .call_entity (entity_id , "run" , run_request .to_dict ()) # type: ignore
454503
455504 # Wrap in DurableAgentTask for response transformation
456505 return DurableAgentTask (
0 commit comments