chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,78 @@
# Human-in-the-Loop Workflow on a Standalone Durable Task Worker
This sample demonstrates a Human-in-the-Loop (HITL) agent-framework `Workflow`
running as a durable orchestration on a **standalone Durable Task worker** — no
Azure Functions required. It is the durabletask counterpart to the Azure
Functions sample `samples/04-hosting/azure_functions/12_workflow_hitl`.
## Key Concepts Demonstrated
- Pausing a workflow for human input with MAF's `ctx.request_info()` /
`@response_handler` pattern, hosted on a standalone worker via
`DurableAIAgentWorker.configure_workflow(workflow)`.
- Discovering pending HITL requests from a client with
`DurableWorkflowClient.get_pending_hitl_requests(instance_id)`.
- Resuming the workflow by sending a decision with
`DurableWorkflowClient.send_hitl_response(instance_id, request_id, response)`.
- Reading the final result with `DurableWorkflowClient.await_workflow_output(instance_id)`.
The workflow is a content-moderation pipeline:
```
input_router -> ContentAnalyzerAgent -> content_analyzer_executor
-> human_review_executor (HITL pause) -> publish_executor
```
## How HITL Works Here
The HITL mechanism is host-agnostic — the same shared workflow orchestrator
drives it on both Azure Functions and a standalone worker:
1. `human_review_executor` calls `ctx.request_info(...)`, which pauses the
workflow. The orchestrator records the open request in its **custom status**
and waits for an external event named by the request's `request_id`.
2. The client reads the custom status via `get_pending_hitl_requests` and sends
a response via `send_hitl_response`, which raises that external event.
3. The orchestrator routes the response back to the executor's
`@response_handler`, and the workflow resumes.
`send_hitl_response` sanitizes the payload (neutralizing pickle-marker
injection) before delivery, since the worker deserializes it.
## Environment Setup
See the [README.md](../README.md) in the parent directory for environment setup.
This sample uses Azure AI Foundry credentials:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
It also needs a Durable Task Scheduler. For local development, start the
emulator (defaults to `http://localhost:8080`):
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
## Running the Sample
Start the worker in one terminal:
```bash
cd samples/04-hosting/durabletask/09_workflow_hitl
python worker.py
```
In a second terminal, run the client:
```bash
python client.py
```
The client runs two cases:
- **Appropriate content** → analyzed → HITL pause → client **approves**
`"Content '...' has been APPROVED and published."`
- **Spammy content** → analyzed → HITL pause → client **rejects**
`"Content '...' has been REJECTED."`
@@ -0,0 +1,131 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client that drives the standalone HITL workflow to completion.
The worker (``worker.py``) must be running first. This client:
1. Starts the workflow with ``DurableWorkflowClient.start_workflow``.
2. Polls ``get_pending_hitl_requests`` until the workflow pauses for human input.
3. Sends a decision with ``send_hitl_response`` (the request_id correlates the
response back to the paused executor).
4. Reads the final output with ``await_workflow_output``.
It runs two cases: appropriate content (approved) and spammy content (rejected).
Prerequisites:
- ``worker.py`` running and connected to the same Durable Task Scheduler.
- A Durable Task Scheduler reachable at ``ENDPOINT`` (default ``http://localhost:8080``).
"""
import asyncio
import logging
import os
import time
from typing import Any
from agent_framework.azure import DurableWorkflowClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
WORKFLOW_NAME = "content_moderation"
def get_client(taskhub: str | None = None, endpoint: str | None = None) -> DurableTaskSchedulerClient:
"""Create a configured DurableTaskSchedulerClient."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerClient(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
)
def _wait_for_hitl_request(
client: DurableWorkflowClient, instance_id: str, timeout_seconds: int = 60
) -> list[dict[str, Any]]:
"""Poll until the workflow has at least one pending HITL request.
Stops early if the workflow reaches a terminal state (e.g. completed or failed)
without pausing, so a misconfiguration or early failure surfaces the real
status instead of a misleading timeout.
"""
terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"}
deadline = time.time() + timeout_seconds
while time.time() < deadline:
pending = client.get_pending_hitl_requests(instance_id)
if pending:
return pending
status = client.get_runtime_status(instance_id)
if status in terminal_statuses:
raise RuntimeError(
f"Workflow instance {instance_id} reached terminal state '{status}' before pausing for human input."
)
time.sleep(2)
raise TimeoutError(f"Timed out waiting for a HITL request on instance {instance_id}")
def run_case(client: DurableWorkflowClient, submission: dict[str, Any], *, approve: bool) -> None:
"""Run one moderation case: start, respond to the HITL pause, print the result."""
instance_id = client.start_workflow(input=submission)
logger.info("Started workflow instance: %s", instance_id)
pending = _wait_for_hitl_request(client, instance_id)
request = pending[0]
logger.info("Pending HITL request %s from %s", request["request_id"], request["source_executor_id"])
decision = {
"approved": approve,
"reviewer_notes": "Looks good." if approve else "Violates content policy.",
}
client.send_hitl_response(instance_id, request["request_id"], decision)
logger.info("Sent decision: approved=%s", approve)
output = client.await_workflow_output(instance_id)
logger.info("Workflow output: %s", output)
async def main() -> None:
"""Run an approved case and a rejected case."""
client = DurableWorkflowClient(get_client(), workflow_name=WORKFLOW_NAME)
logger.info("CASE 1: Appropriate content (will approve)")
run_case(
client,
{
"content_id": "article-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is improving healthcare by enabling faster diagnosis, "
"personalized treatment plans, and better patient outcomes."
),
"author": "Dr. Jane Smith",
},
approve=True,
)
logger.info("CASE 2: Spammy content (will reject)")
run_case(
client,
{
"content_id": "article-002",
"title": "Get Rich Quick",
"body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!",
"author": "Definitely Not Spam",
},
approve=False,
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,344 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a Human-in-the-Loop (HITL) MAF Workflow on a standalone worker.
This sample is the durabletask counterpart to the Azure Functions
``12_workflow_hitl`` sample. It runs an agent-framework ``Workflow`` that pauses
for human approval using MAF's ``ctx.request_info`` / ``@response_handler``
pattern, hosted on a standalone Durable Task worker (no Azure Functions).
``DurableAIAgentWorker.configure_workflow`` auto-registers:
- a durable entity for each agent executor,
- a durable activity for each non-agent executor, and
- the workflow orchestrator (named ``dafx-{workflow.name}``).
When the workflow calls ``ctx.request_info``, the orchestrator pauses and records
the open request in its custom status. An external client discovers the request
(``DurableWorkflowClient.get_pending_hitl_requests``) and resumes the workflow by
sending a response (``DurableWorkflowClient.send_hitl_response``).
The workflow is a content-moderation pipeline:
``input_router`` -> ``ContentAnalyzerAgent`` -> ``content_analyzer_executor``
-> ``human_review_executor`` (HITL pause) -> ``publish_executor``.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
Message,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel, ValidationError
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent"
WORKFLOW_NAME = "content_moderation"
CONTENT_ANALYZER_INSTRUCTIONS = (
"You are a content moderation assistant that analyzes user-submitted content for policy compliance. "
"Evaluate appropriateness, assign a risk level ('low', 'medium', 'high'), list any concerns, and give a "
"brief recommendation for human reviewers. Return JSON with fields is_appropriate (bool), risk_level (str), "
"concerns (list of str), and recommendation (str)."
)
# ============================================================================
# Data Models
# ============================================================================
class ContentAnalysisResult(BaseModel):
"""Structured output from the content analysis agent."""
is_appropriate: bool
risk_level: str
concerns: list[str]
recommendation: str
@dataclass
class ContentSubmission:
"""Content submitted for moderation."""
content_id: str
title: str
body: str
author: str
@dataclass
class AnalysisWithSubmission:
"""Combines the AI analysis with the original submission for downstream processing."""
submission: ContentSubmission
analysis: ContentAnalysisResult
@dataclass
class HumanApprovalRequest:
"""Request sent to a human reviewer. Surfaced to clients via the orchestration status."""
content_id: str
title: str
body: str
author: str
ai_analysis: ContentAnalysisResult
prompt: str
class HumanApprovalResponse(BaseModel):
"""Response the external client sends back via the HITL response endpoint/method."""
approved: bool
reviewer_notes: str = ""
@dataclass
class ModerationResult:
"""Final result of the moderation workflow."""
content_id: str
status: str
reviewer_notes: str
# ============================================================================
# Executors
# ============================================================================
class InputRouterExecutor(Executor):
"""Parses the incoming submission and routes it to the analysis agent."""
def __init__(self) -> None:
super().__init__(id="input_router")
@handler
async def route_input(self, submission: ContentSubmission, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
ctx.set_state("current_submission", submission)
message = (
f"Please analyze the following content for policy compliance:\n\n"
f"Title: {submission.title}\n"
f"Author: {submission.author}\n"
f"Content:\n{submission.body}"
)
await ctx.send_message(
AgentExecutorRequest(messages=[Message(role="user", contents=[message])], should_respond=True)
)
class ContentAnalyzerExecutor(Executor):
"""Parses the AI agent's response and forwards it with the original submission."""
def __init__(self) -> None:
super().__init__(id="content_analyzer_executor")
@handler
async def handle_analysis(
self, response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisWithSubmission]
) -> None:
try:
analysis = ContentAnalysisResult.model_validate_json(response.agent_response.text)
except ValidationError:
analysis = ContentAnalysisResult(
is_appropriate=False,
risk_level="high",
concerns=["Agent execution failed or yielded invalid JSON."],
recommendation="Manual review required",
)
submission: ContentSubmission = ctx.get_state("current_submission")
await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis))
class HumanReviewExecutor(Executor):
"""Requests human approval using MAF's request_info / response_handler pattern."""
def __init__(self) -> None:
super().__init__(id="human_review_executor")
@handler
async def request_review(self, data: AnalysisWithSubmission, ctx: WorkflowContext) -> None:
submission = data.submission
analysis = data.analysis
prompt = (
f"Please review the following content for publication:\n\n"
f"Title: {submission.title}\n"
f"Author: {submission.author}\n"
f"Content: {submission.body}\n\n"
f"AI Analysis:\n"
f"- Appropriate: {analysis.is_appropriate}\n"
f"- Risk Level: {analysis.risk_level}\n"
f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n"
f"- Recommendation: {analysis.recommendation}\n\n"
f"Please approve or reject this content."
)
approval_request = HumanApprovalRequest(
content_id=submission.content_id,
title=submission.title,
body=submission.body,
author=submission.author,
ai_analysis=analysis,
prompt=prompt,
)
# Pause the workflow and wait for a human response.
await ctx.request_info(request_data=approval_request, response_type=HumanApprovalResponse)
@response_handler
async def handle_approval_response(
self,
original_request: HumanApprovalRequest,
response: HumanApprovalResponse,
ctx: WorkflowContext[ModerationResult],
) -> None:
logger.info(
"Human review received for content %s: approved=%s",
original_request.content_id,
response.approved,
)
await ctx.send_message(
ModerationResult(
content_id=original_request.content_id,
status="approved" if response.approved else "rejected",
reviewer_notes=response.reviewer_notes,
)
)
class PublishExecutor(Executor):
"""Finalizes publication or rejection of the content."""
def __init__(self) -> None:
super().__init__(id="publish_executor")
@handler
async def handle_result(self, result: ModerationResult, ctx: WorkflowContext[Never, str]) -> None:
if result.status == "approved":
message = (
f"Content '{result.content_id}' has been APPROVED and published. "
f"Reviewer notes: {result.reviewer_notes or 'None'}"
)
else:
message = (
f"Content '{result.content_id}' has been REJECTED. Reviewer notes: {result.reviewer_notes or 'None'}"
)
logger.info(message)
await ctx.yield_output(message)
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_workflow() -> Workflow:
"""Build the content-moderation workflow with a human-in-the-loop pause."""
chat_client = _create_chat_client()
content_analyzer_agent = Agent(
client=chat_client,
name=CONTENT_ANALYZER_AGENT_NAME,
instructions=CONTENT_ANALYZER_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=ContentAnalysisResult),
)
input_router = InputRouterExecutor()
content_analyzer_executor = ContentAnalyzerExecutor()
human_review_executor = HumanReviewExecutor()
publish_executor = PublishExecutor()
return (
WorkflowBuilder(name=WORKFLOW_NAME, start_executor=input_router)
.add_edge(input_router, content_analyzer_agent)
.add_edge(content_analyzer_agent, content_analyzer_executor)
.add_edge(content_analyzer_executor, human_review_executor)
.add_edge(human_review_executor, publish_executor)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the workflow (agents + activities + orchestrator) on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured HITL workflow with %d executors", len(workflow.executors))
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())