Python: Add load_dotenv() to samples for .env file support (#4043)

* Initial plan

* Add load_dotenv() to 303 Python samples for environment variable loading

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Update SAMPLE_GUIDELINES.md to document load_dotenv() requirement

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Update samples README.md to document .env file usage

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Run ruff format on all changed sample files

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Clarify load_dotenv() usage in README - local dev vs production

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove deprecated getting_started folder as requested

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Document env_file_path parameter for per-client configuration

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Merge main branch to resolve conflicts

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix run_evaluation.py file that was empty in merge commit

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove dotnet changes from merge - out of scope for this PR

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove package and test changes from merge - only sample changes needed

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove test_func_utils.py - only sample changes needed

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Revert sample files not in original changeset - keep only load_dotenv additions

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Move load_dotenv() outside snippet tag in 06_host_your_agent.py

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix comment placement - move load_dotenv before code comments

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix load_dotenv() placement across all samples - after docstring, before code comments

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Merge latest main branch with load_dotenv changes

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove non-sample changes from merge - keep only load_dotenv additions

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Revert non-load_dotenv sample changes from merge

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix run_evaluation.py - use main's improved version (file already had load_dotenv)

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Manual update

* Manual update 2

* Fix Role usage and load_dotenv placement per PR review feedback

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix Role usage - use string literals not enum attributes

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix SAMPLE_GUIDELINES.md example - load_dotenv before docstring per guidance

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Move load_dotenv() before docstrings in all samples per SAMPLE_GUIDELINES ordering

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address PR review: rename files, fix placement, add session usage, remove note

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Update Redis README to reference renamed file redis_history_provider.py

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
This commit is contained in:
Copilot
2026-02-19 10:55:13 +00:00
committed by GitHub
Unverified
parent 3ea9c5fa5d
commit b05fc9e849
317 changed files with 1654 additions and 479 deletions
@@ -18,17 +18,19 @@ import os
from agent_framework.azure import DurableAIAgentClient
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
@@ -53,7 +55,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
@@ -18,8 +18,12 @@ import os
from agent_framework import Agent
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
@@ -38,9 +42,7 @@ def create_joker_agent() -> Agent:
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -65,7 +67,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -19,17 +19,19 @@ import os
from agent_framework.azure import DurableAIAgentClient
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
@@ -54,7 +56,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
@@ -20,8 +20,12 @@ from typing import Any
from agent_framework import tool
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -22,9 +22,13 @@ from datetime import timedelta
import redis.asyncio as aioredis
from agent_framework.azure import DurableAIAgentClient
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from redis_stream_response_handler import RedisStreamResponseHandler
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -54,9 +58,7 @@ async def get_stream_handler() -> RedisStreamResponseHandler:
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableAIAgentClient:
"""Create a configured DurableAIAgentClient.
@@ -81,7 +83,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
return DurableAIAgentClient(dts_client)
@@ -106,7 +108,9 @@ async def stream_from_redis(thread_id: str, cursor: str | None = None) -> None:
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}")
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}")
@@ -175,9 +179,6 @@ def run_client(agent_client: DurableAIAgentClient) -> None:
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
# Create the client
client = get_client()
@@ -25,6 +25,7 @@ class StreamChunk:
is_done: Whether this is the final chunk in the stream.
error: Error message if an error occurred, otherwise None.
"""
entry_id: str
text: str | None = None
is_done: bool = False
@@ -60,7 +61,9 @@ class RedisStreamResponseHandler:
"""Enter async context manager."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object) -> None:
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object
) -> None:
"""Exit async context manager and close Redis connection."""
await self._redis.aclose()
@@ -84,7 +87,7 @@ class RedisStreamResponseHandler:
"text": text,
"sequence": str(sequence),
"timestamp": str(int(time.time() * 1000)),
}
},
)
await self._redis.expire(stream_key, self._stream_ttl)
@@ -107,7 +110,7 @@ class RedisStreamResponseHandler:
"sequence": str(sequence),
"timestamp": str(int(time.time() * 1000)),
"done": "true",
}
},
)
await self._redis.expire(stream_key, self._stream_ttl)
@@ -152,7 +155,7 @@ class RedisStreamResponseHandler:
timeout_seconds = self.MAX_EMPTY_READS * self.POLL_INTERVAL_MS / 1000
yield StreamChunk(
entry_id=start_id,
error=f"Stream not found or timed out after {timeout_seconds} seconds"
error=f"Stream not found or timed out after {timeout_seconds} seconds",
)
return
@@ -4,6 +4,7 @@
In a real application, these would call actual weather and events APIs.
"""
from typing import Annotated
from agent_framework import tool
@@ -26,10 +26,14 @@ from agent_framework.azure import (
DurableAIAgentWorker,
)
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from redis_stream_response_handler import RedisStreamResponseHandler
from tools import get_local_events, get_weather_forecast
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -169,9 +173,7 @@ to make the itinerary easy to scan and visually appealing.""",
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -196,7 +198,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -27,9 +27,7 @@ logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
@@ -54,7 +52,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -67,7 +65,7 @@ def run_client(client: DurableTaskSchedulerClient) -> None:
logger.debug("Starting single agent chaining orchestration...")
# Start the orchestration
instance_id = client.schedule_new_orchestration( # type: ignore
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="single_agent_chaining_orchestration",
input="",
)
@@ -76,10 +74,7 @@ def run_client(client: DurableTaskSchedulerClient) -> None:
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=300
)
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
@@ -20,9 +20,13 @@ from collections.abc import Generator
from agent_framework import Agent, AgentResponse
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -102,10 +106,7 @@ def single_agent_chaining_orchestration(
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}"
)
improved_prompt = f"Improve this further while keeping it under 25 words: {initial_response.text}"
logger.info("[Orchestration] Second agent run: Refining the sentence: %s", improved_prompt)
refined_response = yield writer.run(
@@ -120,9 +121,7 @@ def single_agent_chaining_orchestration(
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -147,7 +146,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -172,7 +171,7 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(single_agent_chaining_orchestration) # type: ignore
worker.add_orchestrator(single_agent_chaining_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {single_agent_chaining_orchestration.__name__}")
return agent_worker
@@ -27,9 +27,7 @@ logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
@@ -54,7 +52,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -66,7 +64,7 @@ def run_client(client: DurableTaskSchedulerClient, prompt: str = "What is temper
prompt: The prompt to send to both agents
"""
# Start the orchestration with the prompt as input
instance_id = client.schedule_new_orchestration( # type: ignore
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="multi_agent_concurrent_orchestration",
input=prompt,
)
@@ -21,9 +21,13 @@ from typing import Any
from agent_framework import Agent, AgentResponse
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import OrchestrationContext, Task, when_all
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -57,7 +61,9 @@ def create_chemist_agent() -> "Agent":
)
def multi_agent_concurrent_orchestration(context: OrchestrationContext, prompt: str) -> Generator[Task[Any], Any, dict[str, str]]:
def multi_agent_concurrent_orchestration(
context: OrchestrationContext, prompt: str
) -> Generator[Task[Any], Any, dict[str, str]]:
"""Orchestration that runs both agents in parallel and aggregates results.
Uses DurableAIAgentOrchestrationContext to wrap the orchestration context and
@@ -84,7 +90,9 @@ def multi_agent_concurrent_orchestration(context: OrchestrationContext, prompt:
physicist_session = physicist.create_session()
chemist_session = chemist.create_session()
logger.debug(f"[Orchestration] Created sessions - Physicist: {physicist_session.session_id}, Chemist: {chemist_session.session_id}")
logger.debug(
f"[Orchestration] Created sessions - Physicist: {physicist_session.session_id}, Chemist: {chemist_session.session_id}"
)
# Create tasks from agent.run() calls - these return DurableAgentTask instances
physicist_task = physicist.run(messages=str(prompt), session=physicist_session)
@@ -112,9 +120,7 @@ def multi_agent_concurrent_orchestration(context: OrchestrationContext, prompt:
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -139,7 +145,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -167,7 +173,7 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(multi_agent_concurrent_orchestration) # type: ignore
worker.add_orchestrator(multi_agent_concurrent_orchestration) # type: ignore
logger.debug(f"✓ Registered orchestration: {multi_agent_concurrent_orchestration.__name__}")
return agent_worker
@@ -25,9 +25,7 @@ logger = logging.getLogger(__name__)
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
@@ -52,14 +50,14 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
def run_client(
client: DurableTaskSchedulerClient,
email_id: str = "email-001",
email_content: str = "Hello! I wanted to reach out about our upcoming project meeting."
email_content: str = "Hello! I wanted to reach out about our upcoming project meeting.",
) -> None:
"""Run client to start and monitor the spam detection orchestration.
@@ -76,7 +74,7 @@ def run_client(
logger.debug("Starting spam detection orchestration...")
# Start the orchestration with the email payload
instance_id = client.schedule_new_orchestration( # type: ignore
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="spam_detection_orchestration",
input=payload,
)
@@ -85,10 +83,7 @@ def run_client(
logger.debug("Waiting for orchestration to complete...")
# Retrieve the final state
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=300
)
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=300)
if metadata and metadata.runtime_status.name == "COMPLETED":
result = metadata.serialized_output
@@ -124,7 +119,7 @@ async def main() -> None:
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week."
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.",
)
# Test with a spam email
@@ -133,7 +128,7 @@ async def main() -> None:
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!"
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:
@@ -25,10 +25,7 @@ from client import get_client, run_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
logging.basicConfig(
level=logging.INFO,
force=True
)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger()
@@ -57,7 +54,7 @@ def main():
run_client(
client,
email_id="email-001",
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week."
email_content="Hello! I wanted to reach out about our upcoming project meeting scheduled for next week.",
)
# Test 2: Spam email
@@ -66,7 +63,7 @@ def main():
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!"
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:
@@ -21,10 +21,14 @@ from typing import Any, cast
from agent_framework import Agent, AgentResponse
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -36,17 +40,20 @@ EMAIL_AGENT_NAME = "EmailAssistantAgent"
class SpamDetectionResult(BaseModel):
"""Result from spam detection agent."""
is_spam: bool
reason: str
class EmailResponse(BaseModel):
"""Result from email assistant agent."""
response: str
class EmailPayload(BaseModel):
"""Input payload for the orchestration."""
email_id: str
email_content: str
@@ -195,9 +202,7 @@ def spam_detection_orchestration(context: OrchestrationContext, payload_raw: Any
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -222,7 +227,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -257,7 +262,7 @@ def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
# Register the orchestration function
logger.debug("Registering orchestration function...")
worker.add_orchestrator(spam_detection_orchestration) # type: ignore[arg-type]
worker.add_orchestrator(spam_detection_orchestration) # type: ignore[arg-type]
logger.debug(f"✓ Registered orchestration: {spam_detection_orchestration.__name__}")
return agent_worker
@@ -31,9 +31,7 @@ HUMAN_APPROVAL_EVENT = "HumanApproval"
def get_client(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient.
@@ -58,7 +56,7 @@ def get_client(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)
@@ -90,11 +88,7 @@ def _log_completion_result(
logger.error("Orchestration did not complete within the timeout period")
def _wait_and_log_completion(
client: DurableTaskSchedulerClient,
instance_id: str,
timeout: int = 60
) -> None:
def _wait_and_log_completion(client: DurableTaskSchedulerClient, instance_id: str, timeout: int = 60) -> None:
"""Wait for orchestration completion and log the result.
Args:
@@ -103,20 +97,12 @@ def _wait_and_log_completion(
timeout: Maximum time to wait for completion in seconds
"""
logger.debug("Waiting for orchestration to complete...")
metadata = client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=timeout
)
metadata = client.wait_for_orchestration_completion(instance_id=instance_id, timeout=timeout)
_log_completion_result(metadata)
def send_approval(
client: DurableTaskSchedulerClient,
instance_id: str,
approved: bool,
feedback: str = ""
) -> None:
def send_approval(client: DurableTaskSchedulerClient, instance_id: str, approved: bool, feedback: str = "") -> None:
"""Send approval or rejection event to the orchestration.
Args:
@@ -125,30 +111,19 @@ def send_approval(
approved: Whether to approve or reject
feedback: Optional feedback message (used when rejected)
"""
approval_data = {
"approved": approved,
"feedback": feedback
}
approval_data = {"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
)
client.raise_orchestration_event(instance_id=instance_id, event_name=HUMAN_APPROVAL_EVENT, data=approval_data)
logger.debug("Event sent successfully")
def wait_for_notification(
client: DurableTaskSchedulerClient,
instance_id: str,
timeout_seconds: int = 10
) -> bool:
def wait_for_notification(client: DurableTaskSchedulerClient, instance_id: str, timeout_seconds: int = 10) -> bool:
"""Wait for the orchestration to reach a notification point.
Polls the orchestration status until it appears to be waiting for approval.
@@ -226,14 +201,14 @@ def run_interactive_client(client: DurableTaskSchedulerClient) -> None:
payload = {
"topic": topic,
"max_review_attempts": max_review_attempts,
"approval_timeout_seconds": approval_timeout_seconds
"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
instance_id = client.schedule_new_orchestration( # type: ignore
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
@@ -25,10 +25,7 @@ from client import get_client, run_interactive_client
from dotenv import load_dotenv
from worker import get_worker, setup_worker
logging.basicConfig(
level=logging.INFO,
force=True
)
logging.basicConfig(level=logging.INFO, force=True)
logger = logging.getLogger()
@@ -22,10 +22,14 @@ from typing import Any, cast
from agent_framework import Agent, AgentResponse
from agent_framework.azure import AzureOpenAIChatClient, DurableAIAgentOrchestrationContext, DurableAIAgentWorker
from azure.identity import AzureCliCredential, DefaultAzureCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from durabletask.task import ActivityContext, OrchestrationContext, Task, when_any # type: ignore
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -37,6 +41,7 @@ HUMAN_APPROVAL_EVENT = "HumanApproval"
class ContentGenerationInput(BaseModel):
"""Input for content generation orchestration."""
topic: str
max_review_attempts: int = 3
approval_timeout_seconds: float = 300 # 5 minutes for demo (72 hours in production)
@@ -44,12 +49,14 @@ class ContentGenerationInput(BaseModel):
class GeneratedContent(BaseModel):
"""Structured output from writer agent."""
title: str
content: str
class HumanApproval(BaseModel):
"""Human approval decision."""
approved: bool
feedback: str = ""
@@ -103,8 +110,7 @@ def publish_content(context: ActivityContext, content: dict[str, str]) -> str:
def content_generation_hitl_orchestration(
context: OrchestrationContext,
payload_raw: Any
context: OrchestrationContext, payload_raw: Any
) -> Generator[Task[Any], Any, dict[str, str]]:
"""Human-in-the-loop orchestration for content generation with approval workflow.
@@ -160,7 +166,7 @@ def content_generation_hitl_orchestration(
initial_response: AgentResponse = yield writer.run(
messages=f"Write a short article about '{payload.topic}'.",
session=writer_session,
options={"response_format": GeneratedContent},
options={"response_format": GeneratedContent},
)
content = cast(GeneratedContent, initial_response.value)
@@ -175,13 +181,12 @@ def content_generation_hitl_orchestration(
attempt += 1
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)")
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",
input=content.model_dump()
)
yield context.call_activity("notify_user_for_approval", input=content.model_dump())
logger.debug("[Orchestration] Waiting for human approval or timeout...")
@@ -217,16 +222,13 @@ def content_generation_hitl_orchestration(
else:
approval = HumanApproval(approved=False, feedback=approval_data)
else:
approval = HumanApproval(approved=False, feedback=str(approval_data)) # type: ignore
approval = HumanApproval(approved=False, feedback=str(approval_data)) # type: ignore
if approval.approved:
# Content approved - publish and return
logger.debug("[Orchestration] Content approved! Publishing...")
context.set_custom_status("Content approved by human reviewer. Publishing...")
publish_task: Task[Any] = context.call_activity(
"publish_content",
input=content.model_dump()
)
publish_task: Task[Any] = context.call_activity("publish_content", input=content.model_dump())
yield publish_task
logger.debug("[Orchestration] Content published successfully")
@@ -256,7 +258,7 @@ def content_generation_hitl_orchestration(
rewrite_response: AgentResponse = yield writer.run(
messages=rewrite_prompt,
session=writer_session,
options={"response_format": GeneratedContent},
options={"response_format": GeneratedContent},
)
rewritten_content = cast(GeneratedContent, rewrite_response.value)
@@ -270,21 +272,15 @@ def content_generation_hitl_orchestration(
# 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)."
)
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(
f"Content could not be approved after {payload.max_review_attempts} iteration(s)."
)
raise RuntimeError(f"Content could not be approved after {payload.max_review_attempts} iteration(s).")
def get_worker(
taskhub: str | None = None,
endpoint: str | None = None,
log_handler: logging.Handler | None = None
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker.
@@ -309,7 +305,7 @@ def get_worker(
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler
log_handler=log_handler,
)