Python: HITL respond-URL addressing from inside workflows#7001
Python: HITL respond-URL addressing from inside workflows#7001ahmedmuhsin wants to merge 4 commits into
Conversation
…ecutors
Let a workflow notify a human reviewer (e.g. email an approval link) from inside the
graph, without the caller threading the instanceId/requestId by hand.
- durabletask: the orchestrator injects host_context {instance_id, workflow_name,
request_path_prefix} into each activity input; CapturingRunnerContext surfaces it as
host_metadata. No new core API.
- azurefunctions: WorkflowHitlContext.from_context(ctx) builds the canonical
respond/status URLs (returns None in-process so callers degrade gracefully).
Re-exported through the agent_framework.azure lazy namespace.
- Nested sub-workflows: the address context (root instance + workflow name +
accumulated {executor}~{ordinal}~ prefix) propagates down call_sub_orchestrator via a
new SUBWORKFLOW_ADDRESS_KEY marker, so an executor at any depth builds a URL that
targets the addressable top-level instance with a qualified request id. The per-child
ordinal matches the read-side enumerate() index used by the status/respond endpoints.
The marker is stripped from untrusted input alongside SUBWORKFLOW_INPUT_KEY
(confused-deputy / info-leak guard).
- Samples 12 and 13 reworked into the retry-safe two-step notify pattern: the emitter
generates an explicit request id and a downstream NotifyExecutor builds the URL and
notifies, so failed upstream retries never produce a dead link.
Tests: unit coverage for the metadata round-trip, address/ordinal agreement (fan-out at
depth and nested prefix accumulation), marker stripping, and URL building; integration
tests assert the helper-built URL equals the server respondUrl and resumes the run, for
both the flat (12) and nested (13) samples.
…g one in samples Add WorkflowHitlContext.pending_request_id(ctx), an async helper that returns the id request_info just generated (read from the runner context's pending request-info events). This works on any host via the core RunnerContext protocol method, so it needs no core change. Samples 12 and 13 now call request_info() and read the id back to forward to the NotifyExecutor, instead of minting a uuid by hand and passing request_id=. The read-back happens in the same activity execution that generated the id, so the pending request event and the notify message still commit together with the same id (retry-safe; failed upstream retries notify no one).
…afety Tighten pending_request_id docstring to require calling it immediately after request_info, and explain why that is safe on the durable host (each executor runs in its own activity with its own runner context, so the pending set only holds this executor's requests and the newest is the one just emitted). Document the two-step notify pattern in the 12 and 13 sample READMEs, including the downstream-notifier retry safety and the nested address-prefix propagation.
There was a problem hiding this comment.
Pull request overview
This PR enables Python Durable Functions workflows (including nested sub-workflows) to construct canonical HITL /respond (and /status) URLs from inside executors, by surfacing orchestration addressing metadata onto the runner context and adding a small helper (WorkflowHitlContext) in agent-framework-azurefunctions.
Changes:
- Surface durable-host orchestration metadata (root instance/workflow + nested request-path prefix) to executors via durabletask runner context
host_metadata. - Add
WorkflowHitlContexthelper to build respond/status URLs and to read back the latestrequest_info-generated request id from the runner context. - Update samples + add unit/integration tests, including nested sub-workflow address propagation and marker-stripping security coverage.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| python/samples/04-hosting/azure_functions/13_subworkflow_hitl/README.md | Documents in-subworkflow notification pattern using WorkflowHitlContext. |
| python/samples/04-hosting/azure_functions/13_subworkflow_hitl/function_app.py | Adds inner-workflow notifier executor and wires it into the nested HITL sample. |
| python/samples/04-hosting/azure_functions/12_workflow_hitl/README.md | Documents in-workflow reviewer notification + respond URL building. |
| python/samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py | Adds notifier executor and routes approval result explicitly to publish step. |
| python/packages/durabletask/tests/test_workflow_serialization.py | Adds tests ensuring subworkflow address marker is stripped from untrusted input. |
| python/packages/durabletask/tests/test_workflow_activity.py | Adds tests asserting host_context is surfaced as runner_context.host_metadata. |
| python/packages/durabletask/tests/test_subworkflow_orchestration.py | Adds tests for address resolution + per-superstep subworkflow ordinal/prefix agreement. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/serialization.py | Introduces SUBWORKFLOW_ADDRESS_KEY and strips it alongside SUBWORKFLOW_INPUT_KEY. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/runner_context.py | Adds host_metadata plumbing to the durable activity runner context. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/orchestrator.py | Threads address context into activity/subworkflow dispatch and propagates nested prefix. |
| python/packages/durabletask/agent_framework_durabletask/_workflows/activity.py | Reads host_context and sets it on the runner context for executor access. |
| python/packages/core/agent_framework/azure/init.pyi | Re-exports WorkflowHitlContext from the agent_framework.azure facade typings. |
| python/packages/core/agent_framework/azure/init.py | Adds lazy export mapping for WorkflowHitlContext. |
| python/packages/azurefunctions/tests/test_hitl_context.py | New unit tests for WorkflowHitlContext URL building/base URL resolution/pending id. |
| python/packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py | Integration test asserts helper-built URL matches server qualified respondUrl (nested). |
| python/packages/azurefunctions/tests/integration_tests/test_12_workflow_hitl.py | Integration test asserts helper-built URL matches server respondUrl (flat). |
| python/packages/azurefunctions/agent_framework_azurefunctions/_hitl_context.py | New helper implementation for HITL URL construction + pending request id read-back. |
| python/packages/azurefunctions/agent_framework_azurefunctions/init.py | Exports WorkflowHitlContext from the package public surface. |
- test_subworkflow_orchestration: replace the mypy-only type:ignore[arg-type] with a cast so the ty checker passes too (the other four checkers already honored the ignore). - samples 12/13 README: guard the notify snippet against None before build_respond_url to match the documented graceful-degradation behavior. - integration tests 12/13: reword comments that implied request_info now generates an explicit uuid4; it generates the id internally by default.
larohra
left a comment
There was a problem hiding this comment.
A few notes on the HITL addressing mechanism -- 2 likely must-fix URL-correctness bugs (hardcoded route prefix, loopback scheme), 2 cheap de-risking refactors (respond/status URL dedup, the cross-package host_context key contract), and 1 optional hardening note on the sub-workflow ordinal. Details inline. Nothing here blocks the durable use case; the first two just affect whether generated links resolve in non-default setups.
|
|
||
| # Default Azure Functions HTTP route prefix; the workflow routes registered by | ||
| # AgentFunctionApp live under ``/api/workflow/...`` to match it. | ||
| _API_ROUTE_PREFIX = "api" |
There was a problem hiding this comment.
This hardcodes the api route prefix, but the prefix is configurable via routePrefix in host.json (and can be set to empty). If a customer customizes it, every respond/status URL built here will 404 on resume. We should derive the prefix from the host config / the same source the route registration uses rather than a literal. Given there's already a customer on this path, worth fixing before merge.
| # infer one (http for local loopback, https otherwise). | ||
| if hostname.startswith(("http://", "https://")): | ||
| return hostname.rstrip("/") | ||
| scheme = "http" if hostname.startswith(("localhost", "127.0.0.1")) else "https" |
There was a problem hiding this comment.
Loopback detection here only covers localhost and 127.0.0.1. func start can also bind 0.0.0.0, and IPv6 loopback is ::1 -- both fall through to https, producing an unreachable link locally. Let's broaden this (or pull it into a small _is_loopback(host) helper so the list lives in one place).
| """ | ||
| qualified_id = f"{self.request_path_prefix}{request_id}" | ||
| return ( | ||
| f"{self.base_url}/{_API_ROUTE_PREFIX}/workflow/" |
There was a problem hiding this comment.
This respond-URL template is a second copy of the respondUrl the server builds in _app.py; build_status_url just below duplicates the status URL the same way. Today they're only kept aligned by the integration test asserting the two strings match -- that "synced by a test" smell is worth removing while the context is fresh. Both call sites are in this package, so let's extract one shared builder -- e.g. build_respond_url(base, workflow_name, instance_id, qualified_id) -- and call it from both the route/status endpoint and here. It also gives the route prefix a single home to reconcile with host.json (see my note on _API_ROUTE_PREFIX).
| # request_path_prefix is the accumulated ``{executor}~{ordinal}~`` hops from the | ||
| # root down to this workflow level. For a top-level workflow the prefix is empty, | ||
| # so this reduces to addressing the instance directly. | ||
| "host_context": { |
There was a problem hiding this comment.
These keys (instance_id, workflow_name, request_path_prefix) are a cross-package contract: WorkflowHitlContext.from_context in the azurefunctions package reads them back by the same literal strings. A rename on either side degrades silently -- from_context just returns None and HITL URLs stop being built, with no error to trace. Let's pin the key names in shared constants (or a small TypedDict) so producer and consumer can't drift.
| # back to the right child. It is deliberately distinct from ``subworkflow_counter`` | ||
| # (a global, cross-superstep counter that only guarantees child-instance-id | ||
| # uniqueness, not addressing position). | ||
| per_executor_sub_ordinal: dict[str, int] = defaultdict(int) |
There was a problem hiding this comment.
Lower priority -- hardening, not a blocker. Your comment above nails the risk: this write-side ordinal has to match the read side's enumerate() index into subworkflows[executorId], and they agree only because both walk the same dispatch order. It would desync silently if anyone ever sorts or filters task_metadata_list. Since you're already appending a TaskMetadata for each child right below, consider stamping the ordinal onto TaskMetadata at dispatch and having the read side consume that -- one source of truth instead of two independent derivations.
Motivation & Context
The durable function HITL flow pauses a workflow with
request_infoand waits for a human to POST a response to the/respondendpoint. The gap was that nothing inside the workflow could build that respond URL. An executor only receives its message payload, not the orchestrationinstanceId, and therequestIddoes not exist untilrequest_inforuns. So to email someone an approval link you had to pull both ids from the HTTP run response or poll the status endpoint from outside the app.This came out of a real POC where someone wanted Agent B to email a reviewer when it finished, then resume to Agent C once approved, but had no way to address the running workflow from within it. This change lets a workflow build its own respond URL and notify a human from inside the graph with no caller plumbing of
instanceIdorrequestId, and it works for nested sub workflows too.Description & Review Guide
What are the major changes?
instanceId, the workflow name, and for nested workflows the request path prefix) onto each executor through the durable runner context. There is no new core API.WorkflowHitlContexthelper inagent-framework-azurefunctionsbuilds the canonical respond and status URLs from that metadata. It returnsNonein process so the same executor degrades gracefully when it is not on the durable host.WorkflowHitlContext.pending_request_id(ctx)reads the idrequest_infojust generated back off the context, so the user never mints an id by hand. It works on any host through the existing core runner context protocol.call_sub_orchestratorvia a newSUBWORKFLOW_ADDRESS_KEYmarker, so an executor at any depth builds a URL that targets the top level instance with a qualified request id. That marker is stripped from untrusted input alongside the existing input marker, so a top level caller cannot forge it.What is the impact of these changes?
respondUrland that posting to it resumes the run, for both the flat (12) and nested (13) samples.What do you want reviewers to focus on?
enumerateindex the status and respond endpoints use when they resolve a nested request. That agreement is the one spot where a mistake would send an emailed URL to the wrong child or a 404.Related Issue
No separate issue. This builds on the Durable Task multi-workflow and sub-workflow hosting work that landed on
mainin #6696, and now targetsmaindirectly.Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.