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
@@ -6,6 +6,10 @@ import os
import httpx
from a2a.client import A2ACardResolver
from agent_framework.a2a import A2AAgent
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Agent2Agent (A2A) Protocol Integration Sample
@@ -12,12 +12,15 @@ from typing import Any
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# 1. Instantiate the agent with the chosen deployment and instructions.
def _create_agent() -> Any:
"""Create the Joker agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
name="Joker",
instructions="You are good at telling jokes.",
@@ -16,15 +16,20 @@ from typing import Any
from agent_framework import tool
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
def get_weather(location: str) -> dict[str, Any]:
"""Get current weather for a location."""
logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})")
result = {
"location": location,
@@ -40,9 +45,7 @@ def get_weather(location: str) -> dict[str, Any]:
def calculate_tip(bill_amount: float, tip_percentage: float = 15.0) -> dict[str, Any]:
"""Calculate tip amount and total bill."""
logger.info(
f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})"
)
logger.info(f"🔧 [TOOL CALLED] calculate_tip(bill_amount={bill_amount}, tip_percentage={tip_percentage})")
tip = bill_amount * (tip_percentage / 100)
total = bill_amount + tip
result = {
@@ -29,9 +29,13 @@ from agent_framework.azure import (
AzureOpenAIChatClient,
)
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from redis_stream_response_handler import RedisStreamResponseHandler, StreamChunk
from tools import get_local_events, get_weather_forecast
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
# Configuration
@@ -217,9 +221,7 @@ async def stream(req: func.HttpRequest) -> func.HttpResponse:
# Get optional cursor from query string
cursor = req.params.get("cursor")
logger.info(
f"Resuming stream for conversation {conversation_id} from cursor: {cursor or '(beginning)'}"
)
logger.info(f"Resuming stream for conversation {conversation_id} from cursor: {cursor or '(beginning)'}")
# Check Accept header to determine response format
accept_header = req.headers.get("Accept", "")
@@ -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
@@ -84,7 +85,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 +108,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 +153,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
@@ -19,6 +19,10 @@ import azure.functions as func
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
@@ -29,7 +33,6 @@ WRITER_AGENT_NAME = "WriterAgent"
# 2. Create the writer agent that will be invoked twice within the orchestration.
def _create_writer_agent() -> Any:
"""Create the writer agent with the same persona as the C# sample."""
instructions = (
"You refine short pieces of text. When given an initial sentence you enhance it;\n"
"when given an improved sentence you polish it further."
@@ -58,10 +61,7 @@ def single_agent_orchestration(context: DurableOrchestrationContext) -> Generato
session=writer_session,
)
improved_prompt = (
"Improve this further while keeping it under 25 words: "
f"{initial.text}"
)
improved_prompt = f"Improve this further while keeping it under 25 words: {initial.text}"
refined = yield writer.run(
messages=improved_prompt,
@@ -20,6 +20,10 @@ from agent_framework import AgentResponse
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
@@ -56,7 +60,6 @@ app.add_agent(agents[1])
@app.orchestration_trigger(context_name="context")
def multi_agent_concurrent_orchestration(context: DurableOrchestrationContext) -> Generator[Any, Any, dict[str, str]]:
"""Fan out to two domain-specific agents and aggregate their responses."""
prompt = context.get_input()
if not prompt or not str(prompt).strip():
raise ValueError("Prompt is required")
@@ -20,8 +20,12 @@ import azure.functions as func
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
# 1. Define agent names shared across the orchestration.
@@ -20,8 +20,12 @@ import azure.functions as func
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from azure.durable_functions import DurableOrchestrationClient, DurableOrchestrationContext
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, ValidationError
# Load environment variables from .env file
load_dotenv()
logger = logging.getLogger(__name__)
# 1. Define orchestration constants used throughout the workflow.
@@ -136,9 +140,7 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext)
)
return {"content": content.content}
context.set_custom_status(
"Content rejected by human reviewer. Incorporating feedback and regenerating..."
)
context.set_custom_status("Content rejected by human reviewer. Incorporating feedback and regenerating...")
# Check if we've exhausted attempts
if attempt >= payload.max_review_attempts:
@@ -162,15 +164,11 @@ def content_generation_hitl_orchestration(context: DurableOrchestrationContext)
context.set_custom_status(
f"Human approval timed out after {payload.approval_timeout_hours} hour(s). Treating as rejection."
)
raise TimeoutError(
f"Human approval timed out after {payload.approval_timeout_hours} hour(s)."
)
raise TimeoutError(f"Human approval timed out after {payload.approval_timeout_hours} hour(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).")
# 5. HTTP endpoint that starts the human-in-the-loop orchestration.
@@ -25,6 +25,10 @@ Authentication uses AzureCliCredential (Azure Identity).
"""
from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Create Azure OpenAI Chat Client
# This uses AzureCliCredential for authentication (requires 'az login')
@@ -62,6 +62,7 @@ RECOMMENDATION_AGENT_NAME = "RecommendationAgent"
class SentimentResult(BaseModel):
"""Result from sentiment analysis."""
sentiment: str # positive, negative, neutral
confidence: float
explanation: str
@@ -69,18 +70,21 @@ class SentimentResult(BaseModel):
class KeywordResult(BaseModel):
"""Result from keyword extraction."""
keywords: list[str]
categories: list[str]
class SummaryResult(BaseModel):
"""Result from summarization."""
summary: str
key_points: list[str]
class RecommendationResult(BaseModel):
"""Result from recommendation engine."""
recommendations: list[str]
priority: str
@@ -88,6 +92,7 @@ class RecommendationResult(BaseModel):
@dataclass
class DocumentInput:
"""Input document to be processed."""
document_id: str
content: str
@@ -95,6 +100,7 @@ class DocumentInput:
@dataclass
class ProcessorResult:
"""Result from a document processor (executor)."""
processor_name: str
document_id: str
content: str
@@ -106,6 +112,7 @@ class ProcessorResult:
@dataclass
class AggregatedResults:
"""Aggregated results from parallel processors."""
document_id: str
content: str
processor_results: list[ProcessorResult]
@@ -114,6 +121,7 @@ class AggregatedResults:
@dataclass
class AgentAnalysis:
"""Analysis result from an agent."""
agent_name: str
result: str
@@ -121,6 +129,7 @@ class AgentAnalysis:
@dataclass
class FinalReport:
"""Final combined report."""
document_id: str
analyses: list[AgentAnalysis]
@@ -131,10 +140,7 @@ class FinalReport:
@executor(id="input_router")
async def input_router(
doc: str,
ctx: WorkflowContext[DocumentInput]
) -> None:
async def input_router(doc: str, ctx: WorkflowContext[DocumentInput]) -> None:
"""Route input document to parallel processors.
Accepts a JSON string from the HTTP request and converts to DocumentInput.
@@ -150,10 +156,7 @@ async def input_router(
@executor(id="word_count_processor")
async def word_count_processor(
doc: DocumentInput,
ctx: WorkflowContext[ProcessorResult]
) -> None:
async def word_count_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None:
"""Process document and count words - runs as an activity."""
logger.info("[word_count_processor] Processing document: %s", doc.document_id)
@@ -174,10 +177,7 @@ async def word_count_processor(
@executor(id="format_analyzer_processor")
async def format_analyzer_processor(
doc: DocumentInput,
ctx: WorkflowContext[ProcessorResult]
) -> None:
async def format_analyzer_processor(doc: DocumentInput, ctx: WorkflowContext[ProcessorResult]) -> None:
"""Analyze document format - runs as an activity in parallel with word_count."""
logger.info("[format_analyzer_processor] Processing document: %s", doc.document_id)
@@ -200,10 +200,7 @@ async def format_analyzer_processor(
@executor(id="aggregator")
async def aggregator(
results: list[ProcessorResult],
ctx: WorkflowContext[AggregatedResults]
) -> None:
async def aggregator(results: list[ProcessorResult], ctx: WorkflowContext[AggregatedResults]) -> None:
"""Aggregate results from parallel processors - receives fan-in input."""
logger.info("[aggregator] Aggregating %d results", len(results))
@@ -221,10 +218,7 @@ async def aggregator(
@executor(id="prepare_for_agents")
async def prepare_for_agents(
aggregated: AggregatedResults,
ctx: WorkflowContext[str]
) -> None:
async def prepare_for_agents(aggregated: AggregatedResults, ctx: WorkflowContext[str]) -> None:
"""Prepare content for agent analysis - broadcasts to multiple agents."""
logger.info("[prepare_for_agents] Preparing content for agents")
@@ -233,10 +227,7 @@ async def prepare_for_agents(
@executor(id="prepare_for_mixed")
async def prepare_for_mixed(
analyses: list[AgentExecutorResponse],
ctx: WorkflowContext[str]
) -> None:
async def prepare_for_mixed(analyses: list[AgentExecutorResponse], ctx: WorkflowContext[str]) -> None:
"""Prepare results for mixed agent+executor parallel processing.
Combines agent analysis results into a string that can be consumed by
@@ -262,10 +253,7 @@ async def prepare_for_mixed(
@executor(id="statistics_processor")
async def statistics_processor(
analysis_text: str,
ctx: WorkflowContext[ProcessorResult]
) -> None:
async def statistics_processor(analysis_text: str, ctx: WorkflowContext[ProcessorResult]) -> None:
"""Calculate statistics from the analysis - runs in parallel with SummaryAgent."""
logger.info("[statistics_processor] Calculating statistics")
@@ -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,
)