Python: [BREAKING] Types API Review improvements (#3647)

* Replace Role and FinishReason classes with NewType + Literal

- Remove EnumLike metaclass from _types.py
- Replace Role class with NewType('Role', str) + RoleLiteral
- Replace FinishReason class with NewType('FinishReason', str) + FinishReasonLiteral
- Update all usages across codebase to use string literals
- Remove .value access patterns (direct string comparison now works)
- Add backward compatibility for legacy dict serialization format
- Update tests to reflect new string-based types

Addresses #3591, #3615

* Simplify ChatResponse and AgentResponse type hints (#3592)

- Remove overloads from ChatResponse.__init__
- Remove text parameter from ChatResponse.__init__
- Remove | dict[str, Any] from finish_reason and usage_details params
- Remove **kwargs from AgentResponse.__init__
- Both now accept ChatMessage | Sequence[ChatMessage] | None for messages
- Update docstrings and examples to reflect changes
- Fix tests that were using removed kwargs
- Fix Role type hint usage in ag-ui utils

* Remove text parameter from ChatResponseUpdate and AgentResponseUpdate (#3597)

- Remove text parameter from ChatResponseUpdate.__init__
- Remove text parameter from AgentResponseUpdate.__init__
- Remove **kwargs from both update classes
- Simplify contents parameter type to Sequence[Content] | None
- Update all usages to use contents=[Content.from_text(...)] pattern
- Fix imports in test files
- Update docstrings and examples

* Rename from_chat_response_updates to from_updates (#3593)

- ChatResponse.from_chat_response_updates → ChatResponse.from_updates
- ChatResponse.from_chat_response_generator → ChatResponse.from_update_generator
- AgentResponse.from_agent_run_response_updates → AgentResponse.from_updates

* Remove try_parse_value method from ChatResponse and AgentResponse (#3595)

- Remove try_parse_value method from ChatResponse
- Remove try_parse_value method from AgentResponse
- Remove try_parse_value calls from from_updates and from_update_generator methods
- Update samples to use try/except with response.value instead
- Update tests to use response.value pattern
- Users should now use response.value with try/except for safe parsing

* Add agent_id to AgentResponse and clarify author_name documentation (#3596)

- Add agent_id parameter to AgentResponse class
- Document that author_name is on ChatMessage objects, not responses
- Update ChatResponse docstring with author_name note
- Update AgentResponse docstring with author_name note

* Simplify ChatMessage.__init__ signature (#3618)

- Make contents a positional argument accepting Sequence[Content | str]
- Auto-convert strings in contents to TextContent
- Remove overloads, keep text kwarg for backward compatibility with serialization
- Update _parse_content_list to handle string items
- Update all usages across codebase to use new format: ChatMessage("role", ["text"])

* Allow Content as input on run and get_response

- Update prepare_messages and normalize_messages to accept Content
- Update type signatures in _agents.py and _clients.py
- Add tests for Content input handling

* Fix ChatMessage usage across packages and samples

Update all remaining ChatMessage(role=..., text=...) to use new
ChatMessage('role', ['text']) signature.

* Fix Role string usage and response format parsing

- Fix redis provider: remove .value access on string literals
- Fix durabletask ensure_response_format: set _response_format before accessing .value

* Fix ollama .value and ai_model_id issues, handle None in content list

- Fix ollama _chat_client: remove .value on string literals
- Fix ollama _chat_client: rename ai_model_id to model_id
- Fix _parse_content_list: skip None values gracefully

* Fix A2AAgent type signature to include Content

* Fix Role/FinishReason NewType dict annotations and improve test coverage to 95%

* Fix mypy errors for Role/FinishReason NewType usage

* Fix Role.TOOL and Role.ASSISTANT usage in _orchestrator_helpers.py

* Fix Role NewType usage in durabletask _models.py
This commit is contained in:
Eduard van Valkenburg
2026-02-04 11:13:23 +01:00
committed by GitHub
Unverified
parent ef798629e5
commit 838a7fd61d
341 changed files with 3766 additions and 3228 deletions
@@ -40,12 +40,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -53,7 +53,7 @@ def get_client(
token_credential=credential,
log_handler=log_handler
)
return DurableAIAgentClient(dts_client)
@@ -66,12 +66,12 @@ def run_client(agent_client: DurableAIAgentClient) -> None:
# Get a reference to the Joker agent
logger.debug("Getting reference to Joker agent...")
joker = agent_client.get_agent("Joker")
# Create a new thread for the conversation
thread = joker.get_new_thread()
logger.debug(f"Thread ID: {thread.session_id}")
logger.info("Start chatting with the Joker agent! (Type 'exit' to quit)")
# Interactive conversation loop
while True:
# Get user input
@@ -80,33 +80,33 @@ def run_client(agent_client: DurableAIAgentClient) -> None:
except (EOFError, KeyboardInterrupt):
logger.info("\nExiting...")
break
# Check for exit command
if user_message.lower() == "exit":
logger.info("Goodbye!")
break
# Skip empty messages
if not user_message:
continue
# Send message to agent and get response
try:
response = joker.run(user_message, thread=thread)
logger.info(f"Joker: {response.text} \n")
except Exception as e:
logger.error(f"Error getting response: {e}")
logger.info("Conversation completed.")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Agent Client...")
# Create client using helper function
agent_client = get_client()
try:
run_client(agent_client)
except Exception as e:
@@ -15,40 +15,40 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
# Configure logging (must be after imports to override their basicConfig)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -52,12 +52,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -78,42 +78,42 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the Joker agent
logger.debug("Creating and registering Joker agent...")
joker_agent = create_joker_agent()
agent_worker.add_agent(joker_agent)
logger.debug(f"✓ Registered agent: {joker_agent.name}")
logger.debug(f" Entity name: dafx-{joker_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Agent Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents
setup_worker(worker)
logger.info("Worker is ready and listening for requests...")
logger.info("Press Ctrl+C to stop.")
logger.info("")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
@@ -41,12 +41,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -54,7 +54,7 @@ def get_client(
token_credential=credential,
log_handler=log_handler
)
return DurableAIAgentClient(dts_client)
@@ -65,45 +65,45 @@ def run_client(agent_client: DurableAIAgentClient) -> None:
agent_client: The DurableAIAgentClient instance
"""
logger.debug("Testing WeatherAgent")
# Get reference to WeatherAgent
weather_agent = agent_client.get_agent("WeatherAgent")
weather_thread = weather_agent.get_new_thread()
logger.debug(f"Created weather conversation thread: {weather_thread.session_id}")
# Test WeatherAgent
weather_message = "What is the weather in Seattle?"
logger.info(f"User: {weather_message}")
weather_response = weather_agent.run(weather_message, thread=weather_thread)
logger.info(f"WeatherAgent: {weather_response.text} \n")
logger.debug("Testing MathAgent")
# Get reference to MathAgent
math_agent = agent_client.get_agent("MathAgent")
math_thread = math_agent.get_new_thread()
logger.debug(f"Created math conversation thread: {math_thread.session_id}")
# Test MathAgent
math_message = "Calculate a 20% tip on a $50 bill"
logger.info(f"User: {math_message}")
math_response = math_agent.run(math_message, thread=math_thread)
logger.info(f"MathAgent: {math_response.text} \n")
logger.debug("Both agents completed successfully!")
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Multi-Agent Client...")
# Create client using helper function
agent_client = get_client()
try:
run_client(agent_client)
except Exception as e:
@@ -15,10 +15,9 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
# Configure logging
@@ -29,26 +28,26 @@ logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -101,12 +101,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -127,43 +127,43 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
weather_agent = create_weather_agent()
math_agent = create_math_agent()
agent_worker.add_agent(weather_agent)
agent_worker.add_agent(math_agent)
logger.debug(f"✓ Registered agents: {weather_agent.name}, {math_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Multi-Agent Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents
setup_worker(worker)
logger.info("Worker is ready and listening for requests...")
logger.info("Press Ctrl+C to stop. \n")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.info("Worker stopped")
@@ -23,7 +23,6 @@ import redis.asyncio as aioredis
from agent_framework.azure import DurableAIAgentClient
from azure.identity import DefaultAzureCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from redis_stream_response_handler import RedisStreamResponseHandler
# Configure logging
@@ -71,12 +70,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
dts_client = DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -84,7 +83,7 @@ def get_client(
token_credential=credential,
log_handler=log_handler
)
return DurableAIAgentClient(dts_client)
@@ -100,33 +99,33 @@ async def stream_from_redis(thread_id: str, cursor: str | None = None) -> None:
logger.debug(f"To manually check Redis, run: redis-cli XLEN {stream_key}")
if cursor:
logger.info(f"Resuming from cursor: {cursor}")
async with await get_stream_handler() as stream_handler:
logger.info(f"Stream handler created, starting to read...")
logger.info("Stream handler created, starting to read...")
try:
chunk_count = 0
async for chunk in stream_handler.read_stream(thread_id, cursor):
chunk_count += 1
logger.debug(f"Received chunk #{chunk_count}: error={chunk.error}, is_done={chunk.is_done}, text_len={len(chunk.text) if chunk.text else 0}")
if chunk.error:
logger.error(f"Stream error: {chunk.error}")
break
if chunk.is_done:
print("\n✓ Response complete!", flush=True)
logger.info(f"Stream completed after {chunk_count} chunks")
break
if chunk.text:
# Print directly to console with flush for immediate display
print(chunk.text, end='', flush=True)
print(chunk.text, end="", flush=True)
if chunk_count == 0:
logger.warning("No chunks received from Redis stream!")
logger.warning(f"Check Redis manually: redis-cli XLEN {stream_key}")
logger.warning(f"View stream contents: redis-cli XREAD STREAMS {stream_key} 0")
except Exception as ex:
logger.error(f"Error reading from Redis: {ex}", exc_info=True)
@@ -140,47 +139,47 @@ def run_client(agent_client: DurableAIAgentClient) -> None:
# Get a reference to the TravelPlanner agent
logger.debug("Getting reference to TravelPlanner agent...")
travel_planner = agent_client.get_agent("TravelPlanner")
# Create a new thread for the conversation
thread = travel_planner.get_new_thread()
if not thread.session_id:
logger.error("Failed to create a new thread with session ID!")
return
key = thread.session_id.key
logger.info(f"Thread ID: {key}")
# Get user input
print("\nEnter your travel planning request:")
user_message = input("> ").strip()
if not user_message:
logger.warning("No input provided. Using default message.")
user_message = "Plan a 3-day trip to Tokyo with emphasis on culture and food"
logger.info(f"\nYou: {user_message}\n")
logger.info("TravelPlanner (streaming from Redis):")
logger.info("-" * 80)
# Start the agent run with wait_for_response=False for non-blocking execution
# This signals the agent to start processing without waiting for completion
# The agent will execute in the background and write chunks to Redis
travel_planner.run(user_message, thread=thread, options={"wait_for_response": False})
# Stream the response from Redis
# This demonstrates that the client can stream from Redis while
# the agent is still processing (or after it completes)
asyncio.run(stream_from_redis(str(key)))
logger.info("\nDemo completed!")
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
# Create the client
client = get_client()
# Run the demo
run_client(client)
@@ -8,9 +8,9 @@ as a message broker. It enables clients to disconnect and reconnect without losi
import asyncio
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import timedelta
from collections.abc import AsyncIterator
import redis.asyncio as aioredis
@@ -20,40 +20,40 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
# Configure logging (must be after imports to override their basicConfig)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Agent Sample with Redis Streaming...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and callbacks using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
agent_client = get_client(log_handler=silent_handler)
try:
# Run client interactions using helper function
run_client(agent_client)
except Exception as e:
logger.exception(f"Error during agent interaction: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -153,13 +153,12 @@ def _get_weather_recommendation(condition: str) -> str:
if "rain" in condition_lower or "drizzle" in condition_lower:
return "Bring an umbrella and waterproof jacket. Consider indoor activities for backup."
elif "fog" in condition_lower:
if "fog" in condition_lower:
return "Morning visibility may be limited. Plan outdoor sightseeing for afternoon."
elif "cold" in condition_lower:
if "cold" in condition_lower:
return "Layer up with warm clothing. Hot drinks and cozy cafés recommended."
elif "hot" in condition_lower or "warm" in condition_lower:
if "hot" in condition_lower or "warm" in condition_lower:
return "Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours."
elif "thunder" in condition_lower or "storm" in condition_lower:
if "thunder" in condition_lower or "storm" in condition_lower:
return "Keep an eye on weather updates. Have indoor alternatives ready."
else:
return "Pleasant conditions expected. Great day for outdoor exploration!"
return "Pleasant conditions expected. Great day for outdoor exploration!"
@@ -27,7 +27,6 @@ from agent_framework.azure import (
)
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from redis_stream_response_handler import RedisStreamResponseHandler
from tools import get_local_events, get_weather_forecast
@@ -186,12 +185,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -212,34 +211,34 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Create the Redis streaming callback
redis_callback = RedisStreamCallback()
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker, callback=redis_callback)
# Create and register the TravelPlanner agent
logger.debug("Creating and registering TravelPlanner agent...")
travel_agent = create_travel_agent()
agent_worker.add_agent(travel_agent)
logger.debug(f"✓ Registered agent: {travel_agent.name}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Agent Worker with Redis Streaming...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agent and callback
setup_worker(worker)
# Start the worker
logger.debug("Worker started and listening for requests...")
worker.start()
try:
# Keep the worker running
while True:
@@ -41,12 +41,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -63,32 +63,32 @@ def run_client(client: DurableTaskSchedulerClient) -> None:
client: The DurableTaskSchedulerClient instance
"""
logger.debug("Starting single agent chaining orchestration...")
# Start the orchestration
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="single_agent_chaining_orchestration",
input="",
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=300
)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
final_text = json.loads(result)
logger.info("Final refined sentence: %s \n", final_text)
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
@@ -100,10 +100,10 @@ def run_client(client: DurableTaskSchedulerClient) -> None:
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Single Agent Chaining Orchestration Client...")
# Create client using helper function
client = get_client()
try:
run_client(client)
except Exception as e:
@@ -21,10 +21,9 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
# Configure logging
@@ -35,22 +34,22 @@ logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Single Agent Orchestration Chaining Sample...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
logger.debug("CLIENT: Starting orchestration...")
# Run the client in the same process
try:
run_client(client)
@@ -60,7 +59,7 @@ def main():
logger.exception(f"Error during orchestration: {e}")
finally:
logger.debug("Worker stopping...")
logger.debug("")
logger.debug("Sample completed")
@@ -11,15 +11,15 @@ Prerequisites:
"""
import asyncio
from collections.abc import Generator
import logging
import os
from collections.abc import Generator
from agent_framework import AgentResponse, ChatAgent
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.task import OrchestrationContext, Task
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -42,7 +42,7 @@ def create_writer_agent() -> "ChatAgent":
"You refine short pieces of text. When given an initial sentence you enhance it;\n"
"when given an improved sentence you polish it further."
)
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name=WRITER_AGENT_NAME,
instructions=instructions,
@@ -78,18 +78,18 @@ def single_agent_chaining_orchestration(
str: The final refined text from the second agent run
"""
logger.debug("[Orchestration] Starting single agent chaining...")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get the writer agent using the agent context
writer = agent_context.get_agent(WRITER_AGENT_NAME)
# Create a new thread for the conversation - this will be shared across both runs
writer_thread = writer.get_new_thread()
logger.debug(f"[Orchestration] Created thread: {writer_thread.session_id}")
prompt = "Write a concise inspirational sentence about learning."
# First run: Generate an initial inspirational sentence
logger.info("[Orchestration] First agent run: Generating initial sentence about: %s", prompt)
@@ -98,21 +98,21 @@ def single_agent_chaining_orchestration(
thread=writer_thread,
)
logger.info(f"[Orchestration] Initial response: {initial_response.text}")
# Second run: Refine the initial response on the same thread
improved_prompt = (
f"Improve this further while keeping it under 25 words: "
f"{initial_response.text}"
)
logger.info("[Orchestration] Second agent run: Refining the sentence: %s", improved_prompt)
refined_response = yield writer.run(
messages=improved_prompt,
thread=writer_thread,
)
logger.info(f"[Orchestration] Refined response: {refined_response.text}")
logger.debug("[Orchestration] Chaining complete")
return refined_response.text
@@ -134,12 +134,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -160,45 +160,45 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the Writer agent
logger.debug("Creating and registering Writer agent...")
writer_agent = create_writer_agent()
agent_worker.add_agent(writer_agent)
logger.debug(f"✓ Registered agent: {writer_agent.name}")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(single_agent_chaining_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {single_agent_chaining_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Single Agent Chaining Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents and orchestrations
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
@@ -41,12 +41,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -68,25 +68,25 @@ def run_client(client: DurableTaskSchedulerClient, prompt: str = "What is temper
orchestrator="multi_agent_concurrent_orchestration",
input=prompt,
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
result_json = json.loads(result) if isinstance(result, str) else result
logger.info("Orchestration Results:\n%s", json.dumps(result_json, indent=2))
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
@@ -98,10 +98,10 @@ def run_client(client: DurableTaskSchedulerClient, prompt: str = "What is temper
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Multi-Agent Orchestration Client...")
# Create client using helper function
client = get_client()
try:
run_client(client)
except Exception as e:
@@ -18,10 +18,9 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
# Configure logging
@@ -32,20 +31,20 @@ logger = logging.getLogger(__name__)
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
# Define the prompt
prompt = "What is temperature?"
logger.debug("CLIENT: Starting orchestration...")
@@ -55,7 +54,7 @@ def main():
run_client(client, prompt)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -11,16 +11,16 @@ Prerequisites:
"""
import asyncio
from collections.abc import Generator
import logging
import os
from collections.abc import Generator
from typing import Any
from agent_framework import AgentResponse, ChatAgent
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.task import OrchestrationContext, when_all, Task
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task, when_all
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -68,44 +68,44 @@ def multi_agent_concurrent_orchestration(context: OrchestrationContext, prompt:
Returns:
dict: Dictionary with 'physicist' and 'chemist' response texts
"""
logger.info(f"[Orchestration] Starting concurrent execution for prompt: {prompt}")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get agents using the agent context (returns DurableAIAgent proxies)
physicist = agent_context.get_agent(PHYSICIST_AGENT_NAME)
chemist = agent_context.get_agent(CHEMIST_AGENT_NAME)
# Create separate threads for each agent
physicist_thread = physicist.get_new_thread()
chemist_thread = chemist.get_new_thread()
logger.debug(f"[Orchestration] Created threads - Physicist: {physicist_thread.session_id}, Chemist: {chemist_thread.session_id}")
# Create tasks from agent.run() calls - these return DurableAgentTask instances
physicist_task = physicist.run(messages=str(prompt), thread=physicist_thread)
chemist_task = chemist.run(messages=str(prompt), thread=chemist_thread)
logger.debug("[Orchestration] Created agent tasks, executing concurrently...")
# Execute both tasks concurrently using when_all
# The DurableAgentTask instances wrap the underlying entity calls
task_results = yield when_all([physicist_task, chemist_task])
logger.debug("[Orchestration] Both agents completed")
# Extract results from the tasks - DurableAgentTask yields AgentResponse
physicist_result: AgentResponse = task_results[0]
chemist_result: AgentResponse = task_results[1]
result = {
"physicist": physicist_result.text,
"chemist": chemist_result.text,
}
logger.debug(f"[Orchestration] Aggregated results ready")
logger.debug("[Orchestration] Aggregated results ready")
return result
@@ -126,12 +126,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -152,48 +152,48 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
physicist_agent = create_physicist_agent()
chemist_agent = create_chemist_agent()
agent_worker.add_agent(physicist_agent)
agent_worker.add_agent(chemist_agent)
logger.debug(f"✓ Registered agents: {physicist_agent.name}, {chemist_agent.name}")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(multi_agent_concurrent_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {multi_agent_concurrent_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Multi-Agent Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents and orchestrations
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
@@ -39,12 +39,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -70,36 +70,36 @@ def run_client(
"email_id": email_id,
"email_content": email_content,
}
logger.debug("Starting spam detection orchestration...")
# Start the orchestration with the email payload
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="spam_detection_orchestration",
input=payload,
)
logger.debug(f"Orchestration started with instance ID: {instance_id}")
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=300
)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug("Orchestration completed successfully!")
# Parse and display the result
if result:
# Remove quotes if present
if result.startswith('"') and result.endswith('"'):
result = result[1:-1]
logger.info(f"Result: {result}")
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
@@ -111,29 +111,29 @@ def run_client(
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task Spam Detection Orchestration Client...")
# Create client using helper function
client = get_client()
try:
# Test with a legitimate email
logger.info("TEST 1: Legitimate Email")
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week."
)
# Test with a spam email
logger.info("TEST 2: Spam Email")
run_client(
client,
email_id="email-002",
email_content="URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!"
)
except Exception as e:
logger.exception(f"Error during orchestration: {e}")
finally:
@@ -18,10 +18,9 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
logging.basicConfig(
@@ -34,43 +33,43 @@ logger = logging.getLogger()
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task Spam Detection Orchestration Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents, orchestrations, and activities using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
logger.debug("CLIENT: Starting orchestration tests...")
try:
# Test 1: Legitimate email
# logger.info("TEST 1: Legitimate Email")
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week."
)
# Test 2: Spam email
logger.info("TEST 2: Spam Email")
run_client(
client,
email_id="email-002",
email_content="URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!"
)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -11,16 +11,16 @@ Prerequisites:
"""
import asyncio
from collections.abc import Generator
import logging
import os
from collections.abc import Generator
from typing import Any, cast
from agent_framework import AgentResponse, ChatAgent
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.task import ActivityContext, OrchestrationContext, Task
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task
from pydantic import BaseModel, ValidationError
# Configure logging
@@ -118,24 +118,24 @@ def spam_detection_orchestration(context: OrchestrationContext, payload_raw: Any
str: Result message from activity functions
"""
logger.debug("[Orchestration] Starting spam detection orchestration")
# Validate input
if not isinstance(payload_raw, dict):
raise ValueError("Email data is required")
try:
payload = EmailPayload.model_validate(payload_raw)
except ValidationError as exc:
raise ValueError(f"Invalid email payload: {exc}") from exc
logger.debug(f"[Orchestration] Processing email ID: {payload.email_id}")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get spam detection agent
spam_agent = agent_context.get_agent(SPAM_AGENT_NAME)
# Run spam detection
spam_prompt = (
"Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) "
@@ -143,52 +143,52 @@ def spam_detection_orchestration(context: OrchestrationContext, payload_raw: Any
f"Email ID: {payload.email_id}\n"
f"Content: {payload.email_content}"
)
logger.info("[Orchestration] Running spam detection agent: %s", spam_prompt)
spam_result_task = spam_agent.run(
messages=spam_prompt,
options={"response_format": SpamDetectionResult},
)
spam_result_raw: AgentResponse = yield spam_result_task
spam_result = cast(SpamDetectionResult, spam_result_raw.value)
logger.info("[Orchestration] Spam detection result: is_spam=%s", spam_result.is_spam)
# Branch based on spam detection result
if spam_result.is_spam:
logger.debug("[Orchestration] Email is spam, handling...")
result_task: Task[str] = context.call_activity("handle_spam_email", input=spam_result.reason)
result: str = yield result_task
return result
# Email is legitimate - draft a response
logger.debug("[Orchestration] Email is legitimate, drafting response...")
email_agent = agent_context.get_agent(EMAIL_AGENT_NAME)
email_prompt = (
"Draft a professional response to this email. Return a JSON response with a 'response' field "
"containing the reply:\n\n"
f"Email ID: {payload.email_id}\n"
f"Content: {payload.email_content}"
)
logger.info("[Orchestration] Running email assistant agent: %s", email_prompt)
email_result_task = email_agent.run(
messages=email_prompt,
options={"response_format": EmailResponse},
)
email_result_raw: AgentResponse = yield email_result_task
email_result = cast(EmailResponse, email_result_raw.value)
logger.debug("[Orchestration] Email response drafted, sending...")
result_task: Task[str] = context.call_activity("send_email", input=email_result.response)
result: str = yield result_task
logger.info("Sent Email: %s", result)
return result
@@ -209,12 +209,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -235,55 +235,55 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register both agents
logger.debug("Creating and registering agents...")
spam_agent = create_spam_agent()
email_agent = create_email_agent()
agent_worker.add_agent(spam_agent)
agent_worker.add_agent(email_agent)
logger.debug(f"✓ Registered agents: {spam_agent.name}, {email_agent.name}")
# Register activity functions
logger.debug("Registering activity functions...")
worker.add_activity(handle_spam_email) # type: ignore[arg-type]
worker.add_activity(send_email) # type: ignore[arg-type]
logger.debug(f"✓ Registered activity: handle_spam_email")
logger.debug(f"✓ Registered activity: send_email")
logger.debug("✓ Registered activity: handle_spam_email")
logger.debug("✓ Registered activity: send_email")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(spam_detection_orchestration) # type: ignore[arg-type]
logger.debug(f"✓ Registered orchestration: {spam_detection_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task Spam Detection Worker with Orchestration...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents, orchestrations, and activities
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")
@@ -45,12 +45,12 @@ def get_client(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -70,16 +70,16 @@ def _log_completion_result(
"""
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
logger.debug(f"Orchestration completed successfully!")
logger.debug("Orchestration completed successfully!")
if result:
try:
result_dict = json.loads(result)
logger.info("Final Result: %s", json.dumps(result_dict, indent=2))
except json.JSONDecodeError:
logger.debug(f"Result: {result}")
elif metadata:
logger.error(f"Orchestration ended with status: {metadata.runtime_status.name}")
if metadata.serialized_output:
@@ -105,7 +105,7 @@ def _wait_and_log_completion(
instance_id=instance_id,
timeout=timeout
)
_log_completion_result(metadata)
@@ -127,18 +127,18 @@ def send_approval(
"approved": approved,
"feedback": feedback
}
logger.debug(f"Sending {'APPROVAL' if approved else 'REJECTION'} to instance {instance_id}")
if feedback:
logger.debug(f"Feedback: {feedback}")
# Raise the external event
client.raise_orchestration_event(
instance_id=instance_id,
event_name=HUMAN_APPROVAL_EVENT,
data=approval_data
)
logger.debug("Event sent successfully")
@@ -160,14 +160,14 @@ def wait_for_notification(
True if notification detected, False if timeout
"""
logger.debug("Waiting for orchestration to reach notification point...")
start_time = time.time()
while time.time() - start_time < timeout_seconds:
try:
metadata = client.get_orchestration_state(
instance_id=instance_id,
)
if metadata:
# Check if we're waiting for approval by examining custom status
if metadata.serialized_custom_status:
@@ -183,19 +183,19 @@ def wait_for_notification(
if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
logger.debug("Orchestration is requesting human feedback")
return True
# Check for terminal states
if metadata.runtime_status.name == "COMPLETED":
logger.debug("Orchestration already completed")
return False
elif metadata.runtime_status.name == "FAILED":
if metadata.runtime_status.name == "FAILED":
logger.error("Orchestration failed")
return False
except Exception as e:
logger.debug(f"Status check: {e}")
time.sleep(1)
logger.warning("Timeout waiting for notification")
return False
@@ -208,94 +208,93 @@ def run_interactive_client(client: DurableTaskSchedulerClient) -> None:
"""
# Get user inputs
logger.debug("Content Generation - Human-in-the-Loop")
topic = input("Enter the topic for content generation: ").strip()
if not topic:
topic = "The benefits of cloud computing"
logger.info(f"Using default topic: {topic}")
max_attempts_str = input("Enter max review attempts (default: 3): ").strip()
max_review_attempts = int(max_attempts_str) if max_attempts_str else 3
timeout_hours_str = input("Enter approval timeout in hours (default: 5): ").strip()
timeout_hours = float(timeout_hours_str) if timeout_hours_str else 5.0
approval_timeout_seconds = int(timeout_hours * 3600)
payload = {
"topic": topic,
"max_review_attempts": max_review_attempts,
"approval_timeout_seconds": approval_timeout_seconds
}
logger.debug(f"Configuration: Topic={topic}, Max attempts={max_review_attempts}, Timeout={timeout_hours}h")
# Start the orchestration
logger.debug("Starting content generation orchestration...")
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
logger.info(f"Orchestration started with instance ID: {instance_id}")
# Review loop
attempt = 1
while attempt <= max_review_attempts:
logger.info(f"Review Attempt {attempt}/{max_review_attempts}")
# Wait for orchestration to reach notification point
logger.debug("Waiting for content generation...")
if not wait_for_notification(client, instance_id, timeout_seconds=120):
logger.error("Failed to receive notification. Orchestration may have completed or failed.")
break
logger.info("Content is ready for review! Please review the content in the worker logs.")
# Get user decision
while True:
decision = input("Do you approve this content? (yes/no): ").strip().lower()
if decision in ['yes', 'y', 'no', 'n']:
if decision in ["yes", "y", "no", "n"]:
break
logger.info("Please enter 'yes' or 'no'")
approved = decision in ['yes', 'y']
approved = decision in ["yes", "y"]
if approved:
logger.debug("Sending approval...")
send_approval(client, instance_id, approved=True)
logger.info("Approval sent. Waiting for orchestration to complete...")
_wait_and_log_completion(client, instance_id, timeout=60)
break
else:
feedback = input("Enter feedback for improvement: ").strip()
if not feedback:
feedback = "Please revise the content."
logger.debug("Sending rejection with feedback...")
send_approval(client, instance_id, approved=False, feedback=feedback)
logger.info("Rejection sent. Content will be regenerated...")
attempt += 1
if attempt > max_review_attempts:
logger.info(f"Maximum review attempts ({max_review_attempts}) reached.")
_wait_and_log_completion(client, instance_id, timeout=30)
break
# Small pause before next iteration
time.sleep(2)
feedback = input("Enter feedback for improvement: ").strip()
if not feedback:
feedback = "Please revise the content."
logger.debug("Sending rejection with feedback...")
send_approval(client, instance_id, approved=False, feedback=feedback)
logger.info("Rejection sent. Content will be regenerated...")
attempt += 1
if attempt > max_review_attempts:
logger.info(f"Maximum review attempts ({max_review_attempts}) reached.")
_wait_and_log_completion(client, instance_id, timeout=30)
break
# Small pause before next iteration
time.sleep(2)
async def main() -> None:
"""Main entry point for the client application."""
logger.debug("Starting Durable Task HITL Content Generation Client")
# Create client using helper function
client = get_client()
try:
run_interactive_client(client)
except KeyboardInterrupt:
logger.info("Interrupted by user")
except Exception as e:
@@ -18,10 +18,9 @@ To run this sample:
import logging
from dotenv import load_dotenv
# Import helper functions from worker and client modules
from client import get_client, run_interactive_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
logging.basicConfig(
@@ -34,28 +33,28 @@ logger = logging.getLogger()
def main():
"""Main entry point - runs both worker and client in single process."""
logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agent, orchestration, and activities using helper function
setup_worker(dts_worker)
# Start the worker
dts_worker.start()
logger.debug("Worker started and listening for requests...")
# Create the client using helper function
client = get_client(log_handler=silent_handler)
try:
logger.debug("CLIENT: Starting orchestration tests...")
run_interactive_client(client)
except Exception as e:
logger.exception(f"Error during sample execution: {e}")
logger.debug("Sample completed. Worker shutting down...")
@@ -11,17 +11,17 @@ Prerequisites:
"""
import asyncio
from collections.abc import Generator
from datetime import timedelta
import logging
import os
from collections.abc import Generator
from datetime import timedelta
from typing import Any, cast
from agent_framework import AgentResponse, ChatAgent
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore
from pydantic import BaseModel, ValidationError
# Configure logging
@@ -64,7 +64,7 @@ def create_writer_agent() -> "ChatAgent":
"Return your response as JSON with 'title' and 'content' fields."
"Limit response to 300 words or less."
)
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name=WRITER_AGENT_NAME,
instructions=instructions,
@@ -85,6 +85,7 @@ def notify_user_for_approval(context: ActivityContext, content: dict[str, str])
logger.info("Use the client to send approval or rejection.")
return "Notification sent to user for approval."
def publish_content(context: ActivityContext, content: dict[str, str]) -> str:
"""Activity function to publish approved content.
@@ -100,7 +101,7 @@ def publish_content(context: ActivityContext, content: dict[str, str]) -> str:
def content_generation_hitl_orchestration(
context: OrchestrationContext,
context: OrchestrationContext,
payload_raw: Any
) -> Generator[Task[Any], Any, dict[str, str]]:
"""Human-in-the-loop orchestration for content generation with approval workflow.
@@ -128,44 +129,44 @@ def content_generation_hitl_orchestration(
RuntimeError: If max review attempts exhausted
"""
logger.debug("[Orchestration] Starting HITL content generation orchestration")
# Validate input
if not isinstance(payload_raw, dict):
raise ValueError("Content generation input is required")
try:
payload = ContentGenerationInput.model_validate(payload_raw)
except ValidationError as exc:
raise ValueError(f"Invalid content generation input: {exc}") from exc
logger.debug(f"[Orchestration] Topic: {payload.topic}")
logger.debug(f"[Orchestration] Max attempts: {payload.max_review_attempts}")
logger.debug(f"[Orchestration] Approval timeout: {payload.approval_timeout_seconds}s")
# Wrap the orchestration context to access agents
agent_context = DurableAIAgentOrchestrationContext(context)
# Get the writer agent
writer = agent_context.get_agent(WRITER_AGENT_NAME)
writer_thread = writer.get_new_thread()
logger.info(f"ThreadID: {writer_thread.session_id}")
# Generate initial content
logger.info("[Orchestration] Generating initial content...")
initial_response: AgentResponse = yield writer.run(
messages=f"Write a short article about '{payload.topic}'.",
thread=writer_thread,
options={"response_format": GeneratedContent},
)
content = cast(GeneratedContent, initial_response.value)
if not isinstance(content, GeneratedContent):
raise ValueError("Agent returned no content after extraction.")
logger.debug(f"[Orchestration] Initial content generated: {content.title}")
# Review loop
attempt = 0
while attempt < payload.max_review_attempts:
@@ -173,34 +174,34 @@ def content_generation_hitl_orchestration(
logger.debug(f"[Orchestration] Review iteration #{attempt}/{payload.max_review_attempts}")
context.set_custom_status(f"Requesting human feedback (Attempt {attempt}, timeout {payload.approval_timeout_seconds}s)")
# Notify user for approval
yield context.call_activity(
"notify_user_for_approval",
"notify_user_for_approval",
input=content.model_dump()
)
logger.debug("[Orchestration] Waiting for human approval or timeout...")
# Wait for approval event or timeout
approval_task: Task[Any] = context.wait_for_external_event(HUMAN_APPROVAL_EVENT) # type: ignore
timeout_task: Task[Any] = context.create_timer( # type: ignore
context.current_utc_datetime + timedelta(seconds=payload.approval_timeout_seconds)
)
# Race between approval and timeout
winner_task = yield when_any([approval_task, timeout_task]) # type: ignore
if winner_task == approval_task:
# Approval received before timeout
logger.debug("[Orchestration] Received human approval event")
context.set_custom_status("Content reviewed by human reviewer.")
# Parse approval
approval_data: Any = approval_task.get_result() # type: ignore
approval_data: Any = approval_task.get_result() # type: ignore
logger.debug(f"[Orchestration] Approval data: {approval_data}")
# Handle different formats of approval_data
if isinstance(approval_data, dict):
approval = HumanApproval.model_validate(approval_data)
@@ -215,7 +216,7 @@ def content_generation_hitl_orchestration(
approval = HumanApproval(approved=False, feedback=approval_data)
else:
approval = HumanApproval(approved=False, feedback=str(approval_data)) # type: ignore
if approval.approved:
# Content approved - publish and return
logger.debug("[Orchestration] Content approved! Publishing...")
@@ -225,52 +226,52 @@ def content_generation_hitl_orchestration(
input=content.model_dump()
)
yield publish_task
logger.debug("[Orchestration] Content published successfully")
return {"content": content.content, "title": content.title}
# Content rejected - incorporate feedback and regenerate
logger.debug(f"[Orchestration] Content rejected. Feedback: {approval.feedback}")
# Check if we've exhausted attempts
if attempt >= payload.max_review_attempts:
context.set_custom_status("Max review attempts exhausted.")
# Max attempts exhausted
logger.error(f"[Orchestration] Max attempts ({payload.max_review_attempts}) exhausted")
break
context.set_custom_status(f"Content rejected by human reviewer. Regenerating...")
context.set_custom_status("Content rejected by human reviewer. Regenerating...")
rewrite_prompt = (
"The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.\n\n"
f"Human Feedback: {approval.feedback or 'No specific feedback provided.'}"
)
logger.debug("[Orchestration] Regenerating content with feedback...")
logger.warning(f"Regenerating with ThreadID: {writer_thread.session_id}")
rewrite_response: AgentResponse = yield writer.run(
messages=rewrite_prompt,
thread=writer_thread,
options={"response_format": GeneratedContent},
)
rewritten_content = cast(GeneratedContent, rewrite_response.value)
if not isinstance(rewritten_content, GeneratedContent):
raise ValueError("Agent returned no content after rewrite.")
content = rewritten_content
logger.debug(f"[Orchestration] Content regenerated: {content.title}")
else:
# Timeout occurred
logger.error(f"[Orchestration] Approval timeout after {payload.approval_timeout_seconds}s")
raise TimeoutError(
f"Human approval timed out after {payload.approval_timeout_seconds} second(s)."
)
# If we exit the loop without returning, max attempts were exhausted
context.set_custom_status("Max review attempts exhausted.")
raise RuntimeError(
@@ -295,12 +296,12 @@ def get_worker(
"""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
logger.debug(f"Using taskhub: {taskhub_name}")
logger.debug(f"Using endpoint: {endpoint_url}")
credential = None if endpoint_url == "http://localhost:8080" else DefaultAzureCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
@@ -321,52 +322,52 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Create and register the writer agent
logger.debug("Creating and registering Writer agent...")
writer_agent = create_writer_agent()
agent_worker.add_agent(writer_agent)
logger.debug(f"✓ Registered agent: {writer_agent.name}")
# Register activity functions
logger.debug("Registering activity functions...")
worker.add_activity(notify_user_for_approval) # type: ignore
worker.add_activity(publish_content) # type: ignore
logger.debug(f"✓ Registered activity: notify_user_for_approval")
logger.debug(f"✓ Registered activity: publish_content")
logger.debug("✓ Registered activity: notify_user_for_approval")
logger.debug("✓ Registered activity: publish_content")
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(content_generation_hitl_orchestration) # type: ignore
worker.add_orchestrator(content_generation_hitl_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {content_generation_hitl_orchestration.__name__}")
return agent_worker
async def main():
"""Main entry point for the worker process."""
logger.debug("Starting Durable Task HITL Content Generation Worker...")
# Create a worker using the helper function
worker = get_worker()
# Setup worker with agents, orchestrations, and activities
setup_worker(worker)
logger.debug("Worker is ready and listening for requests...")
logger.debug("Press Ctrl+C to stop.")
try:
# Start the worker (this blocks until stopped)
worker.start()
# Keep the worker running
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.debug("Worker shutdown initiated")
logger.debug("Worker stopped")