chore: import upstream snapshot with attribution
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
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
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
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Human-in-the-Loop in a Sub-Workflow (Durable Task Worker)
|
||||
|
||||
This sample combines **workflow composition** (`11_subworkflow`) with
|
||||
**human-in-the-loop** (`09_workflow_hitl`): the HITL `request_info` pause lives
|
||||
**inside an inner workflow** that an outer workflow embeds via `WorkflowExecutor`.
|
||||
|
||||
On the durable host the inner workflow runs as its own **child orchestration**, so
|
||||
its pending request is recorded on the *child* instance. The parent records the
|
||||
child instance id in its custom status, which lets the client discover the nested
|
||||
request behind a **single top-level addressing surface**.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- A HITL pause (`ctx.request_info` / `@response_handler`) inside a sub-workflow.
|
||||
- `DurableAIAgentWorker.configure_workflow(outer_workflow)` registers a durable
|
||||
orchestration for each workflow:
|
||||
- `dafx-moderation_pipeline` — the outer workflow.
|
||||
- `dafx-human_review` — the inner (HITL) workflow, run as a child orchestration.
|
||||
- **Qualified request ids:** the nested request surfaces to the client with a
|
||||
qualified id (`review_sub~0~{requestId}`). The client posts the response against the
|
||||
*top-level* instance id, and the host routes it to the owning child orchestration —
|
||||
so the caller never has to discover child instance ids.
|
||||
|
||||
## Composition Layout
|
||||
|
||||
```text
|
||||
moderation_pipeline (outer)
|
||||
intake (executor)
|
||||
-> review_sub = WorkflowExecutor(human_review)
|
||||
review_gate (executor: request_info -> response_handler)
|
||||
-> publish (executor)
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) in the parent directory for environment setup.
|
||||
|
||||
This sample uses **no AI agents**, so no model credentials are required. It only
|
||||
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/12_subworkflow_hitl
|
||||
python worker.py
|
||||
```
|
||||
|
||||
In a second terminal, run the client:
|
||||
|
||||
```bash
|
||||
python client.py
|
||||
```
|
||||
|
||||
Each case flows: `intake` → `review_sub` (child orchestration pauses at
|
||||
`review_gate`) → client responds to the qualified request → `review_gate` resumes →
|
||||
inner decision forwarded to `publish` → final output.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Client that drives the composed HITL workflow, responding to a nested sub-workflow request.
|
||||
|
||||
The worker (``worker.py``) must be running first. This client:
|
||||
|
||||
1. Starts the *outer* workflow with ``DurableWorkflowClient.start_workflow``.
|
||||
2. Polls ``get_pending_hitl_requests`` until a request appears. Because the HITL pause
|
||||
happens inside a sub-workflow, the request surfaces with a **qualified** request id
|
||||
(``review_sub~0~{requestId}``).
|
||||
3. Sends the decision with ``send_hitl_response`` against the *top-level* instance id and
|
||||
the qualified request id; the host routes it to the owning child orchestration.
|
||||
4. Reads the final output with ``await_workflow_output``.
|
||||
|
||||
It runs two cases: approved content and rejected content.
|
||||
|
||||
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__)
|
||||
|
||||
# The client targets the outer workflow; the HITL pause lives in the sub-workflow.
|
||||
WORKFLOW_NAME = "moderation_pipeline"
|
||||
|
||||
|
||||
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 (or one of its sub-workflows) has a pending HITL request.
|
||||
|
||||
Stops early if the workflow reaches a terminal state without pausing, so a
|
||||
misconfiguration 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 nested 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]
|
||||
# The request id is qualified (e.g. "review_sub~0~<uuid>") because the pause lives
|
||||
# in a sub-workflow. We pass it back verbatim against the top-level instance id;
|
||||
# the host resolves it to the owning child orchestration.
|
||||
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."
|
||||
),
|
||||
},
|
||||
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!",
|
||||
},
|
||||
approve=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,274 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Worker hosting a composed workflow whose Human-in-the-Loop pause lives in a sub-workflow.
|
||||
|
||||
This sample combines composition (``11_subworkflow``) with human-in-the-loop
|
||||
(``09_workflow_hitl``): the HITL ``request_info`` happens **inside an inner
|
||||
workflow** that an outer workflow embeds via ``WorkflowExecutor``. On the durable
|
||||
host the inner workflow runs as its own child orchestration, so its pending request
|
||||
is recorded on the *child* instance. The parent records the child instance id in its
|
||||
custom status, which lets the client discover the nested request behind a single
|
||||
top-level addressing surface.
|
||||
|
||||
``DurableAIAgentWorker.configure_workflow`` walks the composition and registers a
|
||||
durable orchestration for each workflow:
|
||||
|
||||
- ``dafx-moderation_pipeline`` - the outer workflow.
|
||||
- ``dafx-human_review`` - the inner workflow (run as a child orchestration), which
|
||||
contains the HITL pause.
|
||||
|
||||
Composition layout::
|
||||
|
||||
moderation_pipeline (outer)
|
||||
intake (executor)
|
||||
-> review_sub = WorkflowExecutor(human_review)
|
||||
review_gate (executor: request_info -> response_handler)
|
||||
-> publish (executor)
|
||||
|
||||
The client sees the inner pending request with a **qualified** request id
|
||||
(``review_sub~0~{requestId}``) and posts the response back to the *top-level*
|
||||
instance; the host routes it to the owning child orchestration automatically.
|
||||
|
||||
Prerequisites:
|
||||
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
|
||||
(This sample uses no AI agents, so no model credentials are required.)
|
||||
|
||||
Run the worker (this process), then run ``client.py`` in another process.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.azure import DurableAIAgentWorker
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Never
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INNER_WORKFLOW_NAME = "human_review"
|
||||
OUTER_WORKFLOW_NAME = "moderation_pipeline"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentSubmission:
|
||||
"""Content submitted for moderation (outer workflow input)."""
|
||||
|
||||
content_id: str
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanApprovalRequest:
|
||||
"""Request surfaced to the human reviewer (carried in the orchestration status)."""
|
||||
|
||||
content_id: str
|
||||
title: str
|
||||
body: str
|
||||
prompt: str
|
||||
|
||||
|
||||
class HumanApprovalResponse(BaseModel):
|
||||
"""Response the external client sends back via the HITL response endpoint/method."""
|
||||
|
||||
approved: bool
|
||||
reviewer_notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationDecision:
|
||||
"""The inner workflow's output: the human's decision for a submission."""
|
||||
|
||||
content_id: str
|
||||
approved: bool
|
||||
reviewer_notes: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Inner workflow (contains the HITL pause)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ReviewGateExecutor(Executor):
|
||||
"""Inner-workflow executor that pauses for human approval via request_info."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="review_gate")
|
||||
|
||||
@handler
|
||||
async def request_review(self, submission: ContentSubmission, ctx: WorkflowContext) -> None:
|
||||
prompt = (
|
||||
f"Please review the following content for publication:\n\n"
|
||||
f"Title: {submission.title}\n"
|
||||
f"Content: {submission.body}\n\n"
|
||||
f"Approve or reject this content."
|
||||
)
|
||||
approval_request = HumanApprovalRequest(
|
||||
content_id=submission.content_id,
|
||||
title=submission.title,
|
||||
body=submission.body,
|
||||
prompt=prompt,
|
||||
)
|
||||
# Pause the (inner) workflow and wait for a human response. On the durable
|
||||
# host this pauses the child orchestration running this inner workflow.
|
||||
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[Never, ModerationDecision],
|
||||
) -> None:
|
||||
logger.info(
|
||||
"Human review received for content %s: approved=%s",
|
||||
original_request.content_id,
|
||||
response.approved,
|
||||
)
|
||||
# Yield the decision as the inner workflow's output; the WorkflowExecutor
|
||||
# forwards it to the outer workflow as a message to the next node.
|
||||
await ctx.yield_output(
|
||||
ModerationDecision(
|
||||
content_id=original_request.content_id,
|
||||
approved=response.approved,
|
||||
reviewer_notes=response.reviewer_notes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_inner_workflow() -> Workflow:
|
||||
"""Build the inner ``human_review`` workflow (a single HITL gate)."""
|
||||
review_gate = ReviewGateExecutor()
|
||||
return WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=review_gate).build()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Outer workflow (embeds the inner workflow)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class IntakeExecutor(Executor):
|
||||
"""Outer-workflow entry point that normalizes the submission before review."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="intake")
|
||||
|
||||
@handler
|
||||
async def intake(self, submission: ContentSubmission, ctx: WorkflowContext[ContentSubmission]) -> None:
|
||||
logger.info("Intake received submission %s", submission.content_id)
|
||||
await ctx.send_message(submission)
|
||||
|
||||
|
||||
class PublishExecutor(Executor):
|
||||
"""Outer-workflow executor that consumes the inner workflow's forwarded decision."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="publish")
|
||||
|
||||
@handler
|
||||
async def handle_decision(self, decision: ModerationDecision, ctx: WorkflowContext[Never, str]) -> None:
|
||||
if decision.approved:
|
||||
message = (
|
||||
f"Content '{decision.content_id}' APPROVED and published. "
|
||||
f"Reviewer notes: {decision.reviewer_notes or 'None'}"
|
||||
)
|
||||
else:
|
||||
message = f"Content '{decision.content_id}' REJECTED. Reviewer notes: {decision.reviewer_notes or 'None'}"
|
||||
logger.info(message)
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
def create_workflow() -> Workflow:
|
||||
"""Build the outer ``moderation_pipeline`` workflow embedding the HITL sub-workflow."""
|
||||
inner_workflow = create_inner_workflow()
|
||||
|
||||
intake = IntakeExecutor()
|
||||
# WorkflowExecutor embeds the inner (HITL) workflow as a single node. On the
|
||||
# durable host this node runs as a child orchestration, and the inner pause
|
||||
# surfaces to the client as a qualified request id (``review_sub~0~{requestId}``).
|
||||
review_sub = WorkflowExecutor(inner_workflow, id="review_sub")
|
||||
publish = PublishExecutor()
|
||||
|
||||
return (
|
||||
WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake)
|
||||
.add_edge(intake, review_sub)
|
||||
.add_edge(review_sub, publish)
|
||||
.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 outer workflow and its nested HITL sub-workflow on the worker."""
|
||||
agent_worker = DurableAIAgentWorker(worker)
|
||||
|
||||
workflow = create_workflow()
|
||||
# A single call registers the outer workflow plus the nested human_review
|
||||
# sub-workflow (each as its own durable orchestration).
|
||||
agent_worker.configure_workflow(workflow)
|
||||
logger.info(
|
||||
"✓ Configured workflow '%s' with embedded HITL sub-workflow '%s'",
|
||||
OUTER_WORKFLOW_NAME,
|
||||
INNER_WORKFLOW_NAME,
|
||||
)
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user