diff --git a/python/packages/core/agent_framework/_oauth.py b/python/packages/core/agent_framework/_oauth.py new file mode 100644 index 00000000000..7982b5cd055 --- /dev/null +++ b/python/packages/core/agent_framework/_oauth.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import re +from urllib.parse import urlparse + +logger = logging.getLogger("agent_framework") + +__all__ = ["validate_oauth_consent_link"] + +# Characters allowed in a registered host name, and in the bracketed IPv6 form that +# ``urlparse`` reports with its brackets already stripped. +_HOST_PATTERN = re.compile(r"^[A-Za-z0-9._~%-]+$") +_IPV6_HOST_PATTERN = re.compile(r"^[0-9A-Fa-f:.%-]+$") + + +def _is_valid_host(hostname: str) -> bool: + """Return whether *hostname* is syntactically usable by a standard URL client. + + ``urlparse`` does not reject hosts containing illegal characters, so values such as + ``exa mple.com`` are reported as a hostname even though no client can resolve them. + """ + pattern = _IPV6_HOST_PATTERN if ":" in hostname else _HOST_PATTERN + return bool(pattern.match(hostname)) + + +def validate_oauth_consent_link(consent_link: str | None, *, item_id: str | None = None) -> str | None: + """Return *consent_link* when it is an absolute HTTPS URL a client can open, else ``None``. + + A consent link is rendered as a clickable prompt by the client, so anything that is not + an absolute ``https`` URL is dropped rather than surfaced. Validation is shared by every + package that parses or re-emits ``oauth_consent_request`` content so the accepted shape + cannot drift between the provider that parses a link and the host that renders it. + + ``urlparse`` is permissive in three ways that matter here, all handled below: + + * it raises ``ValueError`` for malformed authorities (``https://[broken``) and for invalid + ports, but the port is only validated when it is read; + * a non-empty ``netloc`` does not imply a host (``https://@`` has one but no host), and a + non-empty ``hostname`` does not imply a usable one (``https://exa mple.com`` reports one); + * it silently strips tab and newline, so control characters must be rejected up front. + + Args: + consent_link: The candidate consent URL, which may be ``None`` or empty. + + Keyword Args: + item_id: Optional identifier of the source item, included in warning logs. + + Returns: + The link unchanged when it is usable, otherwise ``None``. + """ + if not consent_link: + return None + + log_id = item_id or "" + + if any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in consent_link): + logger.warning( + "Skipping oauth_consent_request with whitespace or control characters in consent_link (item id=%s)", + log_id, + ) + return None + try: + parsed = urlparse(consent_link) + hostname = parsed.hostname + # Reading ``port`` is what validates it; ``https://host:bad`` raises here. + _ = parsed.port + except ValueError: + logger.warning("Skipping oauth_consent_request with malformed consent_link (item id=%s)", log_id) + return None + if parsed.scheme.lower() != "https" or not hostname: + logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", log_id) + return None + if not _is_valid_host(hostname): + logger.warning("Skipping oauth_consent_request with an invalid consent_link host (item id=%s)", log_id) + return None + return consent_link diff --git a/python/packages/core/tests/test_oauth.py b/python/packages/core/tests/test_oauth.py new file mode 100644 index 00000000000..4aeb3fe282d --- /dev/null +++ b/python/packages/core/tests/test_oauth.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from agent_framework._oauth import validate_oauth_consent_link + + +@pytest.mark.parametrize( + "link", + [ + "https://login.example.com/consent", + "https://login.example.com:8443/consent?state=abc#frag", + "https://192.0.2.10/consent", + "https://[2001:db8::1]:8443/consent", + "HTTPS://login.example.com/consent", + ], +) +def test_usable_links_are_returned_unchanged(link: str) -> None: + assert validate_oauth_consent_link(link) == link + + +@pytest.mark.parametrize( + ("link", "reason"), + [ + (None, "missing"), + ("", "empty"), + (" ", "whitespace only"), + ("http://login.example.com/consent", "non-HTTPS scheme"), + ("ftp://login.example.com/consent", "non-HTTPS scheme"), + ("/consent", "relative, so no scheme or host"), + ("https://", "no host"), + ("https://@", "netloc present but no host"), + ("https://[broken", "malformed authority, urlparse raises"), + ("https://login.example.com:bad/consent", "port only validated when read"), + ("https://login.example.com:99999/consent", "port out of range"), + ("https://exa mple.com/consent", "space in host, unresolvable"), + ("https://cons|ent.example.com/", "illegal character in host"), + ("https://login.example.com/consent\n", "trailing newline, silently stripped by urlparse"), + ("https://login.example.com/\tconsent", "embedded tab, silently stripped by urlparse"), + ], +) +def test_unusable_links_are_rejected(link: str | None, reason: str) -> None: + assert validate_oauth_consent_link(link) is None, reason + + +def test_rejection_is_logged_with_the_item_id(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level("WARNING"): + assert validate_oauth_consent_link("http://login.example.com", item_id="item-5") is None + assert "non-HTTPS" in caplog.text + assert "item-5" in caplog.text + + +def test_rejection_without_an_item_id_still_logs(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level("WARNING"): + assert validate_oauth_consent_link("http://login.example.com") is None + assert "non-HTTPS" in caplog.text diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 873d42c3d9c..c06146644b6 100644 --- a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -4,26 +4,21 @@ import logging from typing import Any -from urllib.parse import urlparse from agent_framework import ChatResponseUpdate, Content +from agent_framework._oauth import validate_oauth_consent_link logger = logging.getLogger(__name__) def _validate_consent_link(consent_link: str, item_id: str) -> str: - """Validate a consent link is HTTPS with a valid netloc. + """Validate a consent link is HTTPS with a valid host and port. - Returns the link unchanged if valid, or an empty string if not. + Thin wrapper over the shared core validator that keeps this module's empty-string + contract. The rules live in ``agent_framework._oauth`` so the parser here and the + Foundry hosting layer that re-emits the link cannot drift apart. """ - parsed = urlparse(consent_link) - if parsed.scheme.lower() != "https" or not parsed.netloc: - logger.warning( - "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", - item_id, - ) - return "" - return consent_link + return validate_oauth_consent_link(consent_link, item_id=item_id) or "" def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None: @@ -33,6 +28,10 @@ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate ``response.output_item.added`` carrying an ``oauth_consent_request`` item or a top-level ``response.oauth_consent_requested`` event, or ``None`` so the caller can fall through to the base implementation. + + The consent request is surfaced even when its link is missing or unusable, so that a + turn which cannot proceed is never reported as a silent success. Link validation is + applied for diagnostics here and enforced by the host that renders the link. """ consent_link: str = "" raw_item: Any = None @@ -51,22 +50,32 @@ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate item_id = getattr(raw_item, "id", "") if consent_link: - consent_link = _validate_consent_link(consent_link, item_id) - - contents: list[Content] = [] - if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=raw_item, - ) - ) + # Validation here is diagnostic only. The provider has signalled that the turn + # cannot proceed without consent, so the request is always surfaced: dropping it + # would let a blocked turn finish as a silent success. The host re-validates and + # is the single authority on whether a link is renderable, failing the response + # when it is not. + _validate_consent_link(consent_link, item_id) else: logger.warning( "Received oauth_consent_request output without valid consent_link (item id=%s)", item_id, ) + # ``server_label`` identifies the MCP server that needs consent and is required by + # downstream Responses output items. It is copied into ``additional_properties`` + # because ``raw_representation`` is provider specific and does not survive a + # session round trip. + server_label = getattr(raw_item, "server_label", None) + additional_properties = {"server_label": server_label} if isinstance(server_label, str) and server_label else None + contents: list[Content] = [ + Content.from_oauth_consent_request( + consent_link=consent_link, + additional_properties=additional_properties, + raw_representation=raw_item, + ) + ] + return ChatResponseUpdate( contents=contents, role="assistant", diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 9ae2938ed62..8568ee4593a 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -1655,8 +1655,8 @@ def test_parse_chunk_surfaces_oauth_consent_request() -> None: assert update.raw_representation is mock_event -def test_parse_chunk_skips_non_https_oauth_consent() -> None: - """An oauth_consent_request with a non-HTTPS link is rejected.""" +def test_parse_chunk_surfaces_non_https_oauth_consent() -> None: + """A non-HTTPS link is still surfaced so the host can fail the response.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -1678,11 +1678,12 @@ def test_parse_chunk_skips_non_https_oauth_consent() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "http://insecure.example.com/login" def test_parse_chunk_handles_missing_consent_link() -> None: - """An oauth_consent_request without a consent_link produces no content.""" + """A missing consent_link still surfaces the request, with an empty link.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -1704,11 +1705,12 @@ def test_parse_chunk_handles_missing_consent_link() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "" def test_parse_chunk_handles_empty_string_consent_link() -> None: - """An oauth_consent_request with empty-string consent_link produces no content.""" + """An empty-string consent_link still surfaces the request.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -1730,7 +1732,8 @@ def test_parse_chunk_handles_empty_string_consent_link() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "" def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index db03045dbef..3ade3f94209 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1453,8 +1453,8 @@ def test_parse_chunk_surfaces_oauth_consent_request() -> None: assert update.model == "test-model" -def test_parse_chunk_skips_non_https_oauth_consent() -> None: - """An oauth_consent_request with a non-HTTPS link is rejected.""" +def test_parse_chunk_surfaces_non_https_oauth_consent() -> None: + """A non-HTTPS link is still surfaced so the host can fail the response.""" mock_project = MagicMock() mock_openai = _make_mock_openai_client() @@ -1477,11 +1477,12 @@ def test_parse_chunk_skips_non_https_oauth_consent() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "http://insecure.example.com/login" def test_parse_chunk_handles_missing_consent_link() -> None: - """An oauth_consent_request without a consent_link produces no content.""" + """A missing consent_link still surfaces the request, with an empty link.""" mock_project = MagicMock() mock_openai = _make_mock_openai_client() @@ -1504,11 +1505,12 @@ def test_parse_chunk_handles_missing_consent_link() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "" def test_parse_chunk_handles_empty_string_consent_link() -> None: - """An oauth_consent_request with empty-string consent_link produces no content.""" + """An empty-string consent_link still surfaces the request.""" mock_project = MagicMock() mock_openai = _make_mock_openai_client() @@ -1531,7 +1533,8 @@ def test_parse_chunk_handles_empty_string_consent_link() -> None: update = client._parse_chunk_from_openai(mock_event, {}, {}) consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "" def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py index 2ab209e141e..70904af9f2b 100644 --- a/python/packages/foundry/tests/foundry/test_oauth_helpers.py +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -44,6 +44,83 @@ def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) assert result == "" +def test_validate_consent_link_rejects_malformed_authority(caplog: pytest.LogCaptureFixture) -> None: + """A malformed authority makes urlparse raise ValueError; it must be rejected, not propagated.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("https://[broken", "item-5") + assert result == "" + assert "malformed" in caplog.text + assert "item-5" in caplog.text + + +def test_validate_consent_link_rejects_netloc_without_host(caplog: pytest.LogCaptureFixture) -> None: + """``https://@`` has a netloc but no host, so it is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("https://@", "item-6") + assert result == "" + assert "non-HTTPS" in caplog.text + + +@pytest.mark.parametrize( + "consent_link", + [ + "https://consent.example.com:bad/obo", + "https://consent.example.com:99999/obo", + ], +) +def test_validate_consent_link_rejects_invalid_port(consent_link: str, caplog: pytest.LogCaptureFixture) -> None: + """``urlparse`` only validates the port when it is read, so an invalid port must be caught.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link(consent_link, "item-7") + assert result == "" + assert "malformed" in caplog.text + + +@pytest.mark.parametrize( + "consent_link", + [ + "https://cons|ent.example.com/obo", + "https://exa^mple.com/obo", + ], +) +def test_validate_consent_link_rejects_invalid_host_characters( + consent_link: str, caplog: pytest.LogCaptureFixture +) -> None: + """``urlparse`` reports a hostname for values that no URL client can resolve.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link(consent_link, "item-8") + assert result == "" + assert "invalid consent_link host" in caplog.text + + +@pytest.mark.parametrize( + "consent_link", + [ + "https://cons\tent.example.com/obo", + "https://consent.example.com/obo\n", + ], +) +def test_validate_consent_link_rejects_control_characters(consent_link: str, caplog: pytest.LogCaptureFixture) -> None: + """``urlparse`` strips tab and newline, so they must be rejected before parsing.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link(consent_link, "item-10") + assert result == "" + assert "control characters" in caplog.text + + +@pytest.mark.parametrize( + "consent_link", + [ + "https://consent.example.com/obo", + "https://consent.example.com:8443/obo", + "https://[2001:db8::1]/obo", + ], +) +def test_validate_consent_link_accepts_usable_links(consent_link: str) -> None: + """Valid ports and IPv6 literals stay usable and must not be dropped.""" + assert _validate_consent_link(consent_link, "item-9") == consent_link + + # endregion # region try_parse_oauth_consent_event tests @@ -54,6 +131,7 @@ def _make_output_item_event( item_type: str = "oauth_consent_request", consent_link: Any = "https://consent.example.com/auth", item_id: str = "oauth-item-1", + server_label: Any = "obo-mcp", ) -> MagicMock: """Create a mock ``response.output_item.added`` event.""" event = MagicMock() @@ -62,6 +140,7 @@ def _make_output_item_event( item.type = item_type item.consent_link = consent_link item.id = item_id + item.server_label = server_label event.item = item return event @@ -70,12 +149,14 @@ def _make_top_level_event( *, consent_link: Any = "https://consent.example.com/authorize", event_id: str = "consent-event-1", + server_label: Any = "obo-mcp", ) -> MagicMock: """Create a mock ``response.oauth_consent_requested`` event.""" event = MagicMock() event.type = "response.oauth_consent_requested" event.consent_link = consent_link event.id = event_id + event.server_label = server_label return event @@ -117,48 +198,66 @@ def test_parses_top_level_consent_requested_event() -> None: assert consent[0].consent_link == "https://consent.example.com/authorize" -def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None: - """A non-HTTPS consent_link produces an update with empty contents and logs a warning.""" - event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http") +@pytest.mark.parametrize( + ("consent_link", "expected_link", "expected_log"), + [ + pytest.param("http://bad.example.com/login", "http://bad.example.com/login", "non-HTTPS", id="non-https"), + pytest.param(None, "", "without valid consent_link", id="missing"), + pytest.param("", "", "without valid consent_link", id="empty-string"), + pytest.param("https:///path", "https:///path", "non-HTTPS", id="empty-netloc"), + pytest.param("https://[broken", "https://[broken", "malformed", id="malformed-authority"), + ], +) +def test_unusable_consent_link_is_still_surfaced( + consent_link: str | None, + expected_link: str, + expected_log: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """An unusable link is logged but still surfaced so the host can fail the response. + + Dropping the content here would leave the host with nothing to record, so a turn that + cannot proceed without consent would be reported as a silent success. + """ + event = _make_output_item_event(consent_link=consent_link, item_id="item-bad") with caplog.at_level(logging.WARNING): update = try_parse_oauth_consent_event(event, "test-model") assert update is not None - assert len(update.contents) == 0 - assert "non-HTTPS" in caplog.text + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == expected_link + assert expected_log in caplog.text -def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None: - """A None consent_link produces an update with empty contents and logs a warning.""" - event = _make_output_item_event(consent_link=None, item_id="item-none") - with caplog.at_level(logging.WARNING): - update = try_parse_oauth_consent_event(event, "test-model") +def test_server_label_is_preserved_in_additional_properties() -> None: + """The upstream item's server_label is carried forward so hosting can re-emit it.""" + event = _make_output_item_event(server_label="work-iq-connection") + update = try_parse_oauth_consent_event(event, "test-model") assert update is not None - assert len(update.contents) == 0 - assert "without valid consent_link" in caplog.text + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert consent[0].additional_properties["server_label"] == "work-iq-connection" -def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None: - """An empty-string consent_link produces an update with empty contents and logs a warning.""" - event = _make_output_item_event(consent_link="", item_id="item-empty") - with caplog.at_level(logging.WARNING): - update = try_parse_oauth_consent_event(event, "test-model") +def test_top_level_event_server_label_is_preserved() -> None: + """The top-level consent event's server_label is carried forward too.""" + event = _make_top_level_event(server_label="work-iq-connection") + update = try_parse_oauth_consent_event(event, "test-model") assert update is not None - assert len(update.contents) == 0 - assert "without valid consent_link" in caplog.text + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert consent[0].additional_properties["server_label"] == "work-iq-connection" -def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: - """An HTTPS URL with empty netloc (https:///path) is rejected.""" - event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc") - with caplog.at_level(logging.WARNING): - update = try_parse_oauth_consent_event(event, "test-model") +def test_missing_server_label_leaves_additional_properties_empty() -> None: + """A non-string server_label is ignored rather than stored.""" + event = _make_output_item_event(server_label=None) + update = try_parse_oauth_consent_event(event, "test-model") assert update is not None - assert len(update.contents) == 0 - assert "non-HTTPS" in caplog.text + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert "server_label" not in consent[0].additional_properties # endregion diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index d947197a85e..065df1be735 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -9,7 +9,7 @@ import os from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Generator, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, aclosing, suppress -from dataclasses import asdict, dataclass, is_dataclass +from dataclasses import asdict, dataclass, field, is_dataclass from typing import Generic, Literal, TypeVar, cast from agent_framework import ( @@ -26,8 +26,10 @@ SupportsAgentRun, UsageDetails, WorkflowAgent, + WorkflowCheckpoint, add_usage_details, ) +from agent_framework._oauth import validate_oauth_consent_link from agent_framework._telemetry import mark_feature_used from agent_framework.exceptions import AgentFrameworkException from azure.ai.agentserver.core import get_request_context @@ -319,6 +321,125 @@ def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None: return None +def _validated_consent_link(consent_link: str | None) -> str | None: + """Return *consent_link* when it is an absolute HTTPS URL a client can open, else ``None``. + + Thin wrapper over the shared core validator. The rules live in ``agent_framework._oauth`` + so this host and the Foundry parser that produces the link cannot drift apart. + """ + return validate_oauth_consent_link(consent_link) + + +def _consent_link_from_content(content: Content) -> str | None: + """Return a validated consent link for an ``oauth_consent_request`` content. + + Returns ``None`` when *content* is not an OAuth consent request, when it carries + no consent link, or when the link fails :func:`_validated_consent_link`. + """ + if content.type != "oauth_consent_request": + return None + if not content.consent_link: + logger.warning("Received oauth_consent_request content without a consent_link; skipping.") + return None + return _validated_consent_link(content.consent_link) + + +def _emit_oauth_consent_item( + stream: ResponseEventStream, + response_id: str, + consent_link: str, + server_label: str, +) -> Generator[ResponseStreamEvent]: + """Yield the added/done events for an ``oauth_consent_request`` output item.""" + oauth_item = OAuthConsentRequestOutputItem( + id=IdGenerator.new_id("oacr"), + response_id=response_id, + type="oauth_consent_request", + consent_link=consent_link, + server_label=server_label, + ) + builder = stream.add_output_item(oauth_item["id"]) + yield builder.emit_added(oauth_item) + yield builder.emit_done(oauth_item) + + +def _consent_incomplete_reason(count: int) -> str: + """Return the ``response.incomplete`` reason for *count* pending consent requests.""" + return f"OAuth consent required for {count} tool(s)." + + +def _consent_unusable_link_error(count: int) -> RuntimeError: + """Return the error reported when consent is required but no link can be surfaced.""" + return RuntimeError( + f"OAuth consent is required for {count} tool(s), but no usable HTTPS consent link was " + "provided, so the request cannot be completed." + ) + + +def _consent_not_resumable_error(count: int) -> RuntimeError: + """Return the error reported when a workflow turn is blocked on consent it cannot resume from. + + OAuth consent has no response content type, so a caller cannot answer the pending request + an ``AgentExecutor`` records for it. Reporting ``incomplete`` would promise a continuation + that the workflow cannot honor, so the turn fails with the consent link already surfaced + and the user is directed to a new conversation. + """ + return RuntimeError( + f"OAuth consent is required for {count} tool(s). The consent link has been provided, but this " + "workflow turn cannot be resumed after consent is granted because OAuth consent has no response " + "type. Grant consent using the link above, then start a new conversation." + ) + + +def _pending_consent_links(checkpoint: WorkflowCheckpoint | None) -> set[str]: + """Return the consent links a *checkpoint* is parked on, if any. + + A workflow that stops on an OAuth consent request records it as a pending + ``request_info`` event. Consent has no response content type, so nothing a later turn + sends can satisfy that request and the workflow can never be resumed from it. Reading + the pending events off the checkpoint lets the host report the block itself; the + restore-only run does not replay these requests as updates. + """ + if checkpoint is None: + return set() + links: set[str] = set() + for event in checkpoint.pending_request_info_events.values(): + data = getattr(event, "data", None) + if isinstance(data, Content) and data.type == "oauth_consent_request": + links.add(data.consent_link or "") + return links + + +@dataclass +class _ConsentTracker: + """Tracks the OAuth consent requests seen while converting content for one response. + + A consent request means the turn is blocked until the user grants access, so the + outcome has to be recorded even when the link cannot be shown. ``emitted`` drives the + terminal ``response.incomplete``, while ``dropped`` records requests whose link was + unusable so that the response fails instead of silently reporting success. Both are + keyed by ``(consent_link, server_label)`` because a ``WorkflowAgent`` replays the + inner agent's content as workflow output, so the same request can arrive twice. + """ + + emitted: set[tuple[str, str]] = field(default_factory=set[tuple[str, str]]) + dropped: set[tuple[str, str]] = field(default_factory=set[tuple[str, str]]) + + +def _consent_server_label(content: Content) -> str: + """Return the server label to report for an ``oauth_consent_request`` content. + + Prefers the label carried in ``additional_properties``; falls back to the provider's + raw output item, which carries ``server_label`` as a required field. The raw + representation is provider specific and does not survive a session round trip, so it + is only a fallback. + """ + label = content.additional_properties.get("server_label") if content.additional_properties else None + if not isinstance(label, str) or not label: + label = getattr(content.raw_representation, "server_label", None) + return label if isinstance(label, str) and label else "agent_framework" + + # endregion Foundry Toolbox Auth integration @@ -513,29 +634,32 @@ async def _handle_response( try: await self._ensure_agent_ready() except AgentFrameworkException as ex: - consent_errors_to_emit = consent_url_from_error(ex) - if consent_errors_to_emit is None or len(consent_errors_to_emit) == 0: + consent_errors = consent_url_from_error(ex) + # A consent link the client cannot render is not an actionable consent prompt, so + # invalid links are dropped here and an entry-time failure with no usable link left + # is reported as ``response.failed`` rather than an ``incomplete`` the user cannot act on. + consent_errors_to_emit = [ + (consent_error, link) + for consent_error in consent_errors or [] + if (link := _validated_consent_link(consent_error.consent_url)) is not None + ] + if not consent_errors_to_emit: logger.error("Failed to prepare agent: %s", ex, exc_info=(type(ex), ex, ex.__traceback__)) for event in self._emit_failure(response_event_stream, None, ex): yield event return - for consent_error in consent_errors_to_emit: - logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url) - oauth_item = OAuthConsentRequestOutputItem( - id=IdGenerator.new_id("oacr"), - response_id=context.response_id, - type="oauth_consent_request", - consent_link=consent_error.consent_url, - server_label=consent_error.name, - ) - builder = response_event_stream.add_output_item(oauth_item["id"]) - yield builder.emit_added(oauth_item) - yield builder.emit_done(oauth_item) + for consent_error, consent_link in consent_errors_to_emit: + logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_link) + for event in _emit_oauth_consent_item( + response_event_stream, + context.response_id, + consent_link, + consent_error.name, + ): + yield event - yield response_event_stream.emit_incomplete( - reason=f"OAuth consent required for {len(consent_errors_to_emit)} tool(s)." - ) + yield response_event_stream.emit_incomplete(reason=_consent_incomplete_reason(len(consent_errors_to_emit))) return tracker = _OutputItemTracker(response_event_stream) @@ -557,7 +681,10 @@ async def _handle_response( for event in tracker.close(): yield event - yield response_event_stream.emit_completed(usage=tracker.usage) + for event in self._finish_consent_response( + response_event_stream, tracker, resumable=not self._is_workflow_agent + ): + yield event except Exception as ex: logger.error("Failed to produce response for agent", exc_info=(type(ex), ex, ex.__traceback__)) for event in tracker.close(): @@ -816,6 +943,17 @@ async def _handle_inner_workflow( if cancellation_signal.is_set(): return + # OAuth consent is the exception to the pending-request handling above: it has + # no response content type, so a pending consent request can never be fulfilled + # by anything this turn carries. Those requests are read straight off the + # checkpoint, because the restore-only call does not replay them as updates, and + # reported directly instead of letting the run below fail with an internal + # "unexpected content type" error. Raising lets the caller emit the single + # terminal failure event, the same way an unresumable previous_response_id does. + pending_consent_links = _pending_consent_links(latest_checkpoint) + if pending_consent_links: + raise _consent_not_resumable_error(len(pending_consent_links)) + # A cancel signal that fired after the restore-only replay finished (or was never # entered) must still preempt starting a brand new workflow run below. if cancellation_signal.is_set(): @@ -898,6 +1036,51 @@ async def _resume_workflow_from_checkpoint( ): yield update + @classmethod + def _finish_consent_response( + cls, + response_event_stream: ResponseEventStream, + tracker: _OutputItemTracker, + *, + resumable: bool, + ) -> Generator[ResponseStreamEvent]: + """Yield the terminal event for a turn that may have requested OAuth consent. + + Shared by the agent and workflow paths so both report the same outcome for the + same consent state. Callers must apply any higher-precedence failure (a request + or session-persistence error) before delegating here. + + The precedence is: a surfaced consent link ends the turn as ``incomplete`` when the + turn can be continued, and as ``failed`` when it cannot, so the status never promises + a continuation the caller will not get; a consent request whose link was unusable ends + the turn as ``failed``, because there is nothing for the user to act on and + ``completed`` would hide the block; otherwise the turn ``completed``. + + Args: + response_event_stream: The stream the terminal event is emitted on. + tracker: The active output item tracker, drained before a failure event, and the + owner of the consent requests seen while converting this response. + + Keyword Args: + resumable: Whether the turn can continue once consent is granted. True for a + plain agent, which re-runs and picks up the newly granted access. False for + a workflow, where the blocked turn is recorded as a pending request that has + no matching response type and therefore cannot be answered. + """ + consent_tracker = tracker.consent + if consent_tracker.emitted: + count = len(consent_tracker.emitted) + if resumable: + yield response_event_stream.emit_incomplete(reason=_consent_incomplete_reason(count)) + else: + yield from cls._emit_failure(response_event_stream, tracker, _consent_not_resumable_error(count)) + elif consent_tracker.dropped: + yield from cls._emit_failure( + response_event_stream, tracker, _consent_unusable_link_error(len(consent_tracker.dropped)) + ) + else: + yield response_event_stream.emit_completed(usage=tracker.usage) + @staticmethod def _emit_failure( response_event_stream: ResponseEventStream, @@ -957,6 +1140,11 @@ def __init__(self, stream: ResponseEventStream) -> None: self._fc_builder: OutputItemFunctionCallBuilder | None = None self._mcp_builder: OutputItemMcpCallBuilder | None = None self._outstanding_function_calls: dict[str, str | None] = {} + # OAuth consent requests seen while converting this response's content. Lives on the + # tracker because the tracker is created once per response and is already threaded + # through both the agent and workflow paths, and it is read once the inner handler + # finishes to pick the terminal event. + self.consent = _ConsentTracker() @property def usage(self) -> ResponseUsage | None: @@ -1195,6 +1383,36 @@ async def handle( "could not be extracted from the stream event." ) + elif content.type == "oauth_consent_request": + # An OBO/on-behalf-of tool can require consent mid-run, after the agent has already + # been entered. Surface the link as an `oauth_consent_request` output item so the + # client can render a consent prompt instead of an empty assistant turn. Without + # this branch the content falls through to the `else` below and is dropped with + # only a warning, leaving the user no way to grant access. + for event in self._close(): + yield event + server_label = _consent_server_label(content) + consent_link = _consent_link_from_content(content) + if consent_link is None: + # The turn is still blocked on consent even though the link cannot be rendered, + # so record it: reporting success here would look exactly like the silent drop + # this branch exists to fix. + self.consent.dropped.add((content.consent_link or "", server_label)) + else: + # A `WorkflowAgent` replays the inner agent's content as workflow output, so the + # same consent request can arrive more than once in one response. Emitting it + # twice would show the user duplicate consent prompts. + consent_key = (consent_link, server_label) + if consent_key not in self.consent.emitted: + self.consent.emitted.add(consent_key) + for event in _emit_oauth_consent_item( + self._stream, + str(self._stream.response["id"]), + consent_link, + server_label, + ): + yield event + elif content.type == "usage": self._usage_details = add_usage_details(self._usage_details, content.usage_details) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index f82ffb3e541..1ac671bdaf7 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -16,6 +16,7 @@ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path +from types import SimpleNamespace from typing import Literal, cast, overload from unittest.mock import AsyncMock, MagicMock, patch @@ -4028,6 +4029,233 @@ async def test_retry_after_consent_succeeds(self) -> None: agent.run.assert_called_once() +class TestMidRunOAuthConsentSurfacing: + """A tool can require consent after the agent has been entered (e.g. an on-behalf-of + MCP server needing a per-user token), in which case the consent link arrives as + ``oauth_consent_request`` content in the agent's stream rather than as a connect-time error. + """ + + async def test_streaming_mid_run_consent_content_emits_oauth_output_item(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate(contents=[Content.from_text("one moment")], role="assistant"), + AgentResponseUpdate( + contents=[ + Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + additional_properties={"server_label": "obo-mcp"}, + ) + ], + role="assistant", + ), + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=True) + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[-1] == "response.incomplete" + + added = [e for e in events if e["event"] == "response.output_item.added"] + oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"] + assert len(oauth_added) == 1 + assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/obo" + assert oauth_added[0]["data"]["item"]["server_label"] == "obo-mcp" + + done = [e for e in events if e["event"] == "response.output_item.done"] + assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done) + + async def test_non_streaming_mid_run_consent_content_emits_oauth_output_item(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_oauth_consent_request(consent_link="https://consent.example.com/obo")], + ) + ] + ) + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=False) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "incomplete" + + oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"] + assert len(oauth_items) == 1 + assert oauth_items[0]["consent_link"] == "https://consent.example.com/obo" + assert oauth_items[0]["server_label"] == "agent_framework" + + async def test_multiple_consent_contents_each_emit_an_item(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content.from_oauth_consent_request(consent_link="https://consent.example.com/one"), + Content.from_oauth_consent_request(consent_link="https://consent.example.com/two"), + ], + role="assistant", + ) + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=True) + events = _parse_sse_events(resp.text) + added = [e for e in events if e["event"] == "response.output_item.added"] + oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"] + assert len(oauth_added) == 2 + assert {e["data"]["item"]["id"] for e in oauth_added} != {""} + assert len({e["data"]["item"]["id"] for e in oauth_added}) == 2 + + incomplete = [e for e in events if e["event"] == "response.incomplete"] + assert len(incomplete) == 1 + assert "2 tool(s)" in json.dumps(incomplete[0]["data"]) + + async def test_repeated_consent_content_emits_one_item(self) -> None: + """The same consent request arriving twice must not produce duplicate prompts.""" + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content.from_oauth_consent_request(consent_link="https://consent.example.com/obo")], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_oauth_consent_request(consent_link="https://consent.example.com/obo")], + role="assistant", + ), + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=True) + events = _parse_sse_events(resp.text) + added = [e for e in events if e["event"] == "response.output_item.added"] + oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"] + assert len(oauth_added) == 1 + + incomplete = [e for e in events if e["event"] == "response.incomplete"] + assert len(incomplete) == 1 + assert "1 tool(s)" in json.dumps(incomplete[0]["data"]) + + @pytest.mark.parametrize( + "consent_link", + [ + "", + "http://consent.example.com/obo", + "not-a-url", + "https:///path", + "https://[broken", + "https://@", + "https://consent.example.com:bad/obo", + "https://consent.example.com:99999/obo", + "https://cons ent.example.com/obo", + "https://cons|ent.example.com/obo", + ], + ) + async def test_unusable_consent_link_fails_the_response(self, consent_link: str) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content(type="oauth_consent_request", consent_link=consent_link or None)], + role="assistant", + ), + AgentResponseUpdate(contents=[Content.from_text("done")], role="assistant"), + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=True) + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + added = [e for e in events if e["event"] == "response.output_item.added"] + assert not any(e["data"]["item"]["type"] == "oauth_consent_request" for e in added) + # The turn is still blocked on consent, so reporting success would repeat the silent + # drop this feature exists to fix. There is no link to act on, hence `failed`. + assert types[-1] == "response.failed" + assert "response.completed" not in types + assert "response.incomplete" not in types + failed = [e for e in events if e["event"] == "response.failed"] + assert "OAuth consent is required" in failed[0]["data"]["response"]["error"]["message"] + + async def test_usable_consent_link_still_wins_over_an_unusable_one(self) -> None: + """A response with one renderable link stays actionable, so it ends `incomplete`.""" + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content(type="oauth_consent_request", consent_link="http://insecure.example.com/obo"), + Content(type="oauth_consent_request", consent_link="https://consent.example.com/obo"), + ], + role="assistant", + ), + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=True) + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + added = [ + e + for e in events + if e["event"] == "response.output_item.added" and e["data"]["item"]["type"] == "oauth_consent_request" + ] + assert len(added) == 1 + assert added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/obo" + assert types[-1] == "response.incomplete" + + async def test_server_label_falls_back_to_raw_representation(self) -> None: + """The Foundry parser carries ``server_label`` in additional properties, but a + content that only has the provider's raw item must still report its label. + """ + raw_item = SimpleNamespace(server_label="raw-obo-mcp") + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + raw_representation=raw_item, + ) + ], + role="assistant", + ) + ] + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=False) + body = resp.json() + oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"] + assert len(oauth_items) == 1 + assert oauth_items[0]["server_label"] == "raw-obo-mcp" + + async def test_connect_time_invalid_consent_link_fails_the_response(self) -> None: + """An entry-time consent error whose link cannot be rendered leaves nothing for the + user to act on, so the response fails instead of reporting ``incomplete``. + """ + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.__aenter__.side_effect = _make_consent_error("http://insecure.example.com/consent") + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=False) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "failed" + assert not any(it["type"] == "oauth_consent_request" for it in body.get("output", [])) + agent.run.assert_not_called() + + # endregion # region Error handling (response.failed surfacing) @@ -4367,13 +4595,18 @@ async def _iter() -> AsyncIterator[AgentResponseUpdate]: def _build_text_workflow_agent(text: str) -> WorkflowAgent: """Build a minimal ``WorkflowAgent`` whose inner agent emits a fixed text.""" + return _build_contents_workflow_agent([Content.from_text(text=text)]) + + +def _build_contents_workflow_agent(contents: list[Content]) -> WorkflowAgent: + """Build a minimal ``WorkflowAgent`` whose inner agent emits fixed contents.""" class _TextAgent(SupportsAgentRun): - def __init__(self, name: str, text: str) -> None: + def __init__(self, name: str, contents: list[Content]) -> None: self.id = str(uuid.uuid4()) self.name = name self.description: str | None = None - self._text = text + self._contents = contents def create_session(self, **kwargs: Any) -> AgentSession: del kwargs @@ -4415,19 +4648,19 @@ def run( ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: del messages, session, kwargs assert stream is True, "The inner agent only runs in stream mode in Foundry Hosted Agents." - text = self._text + agent_contents = self._contents name = self.name async def _aiter() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate( - contents=[Content.from_text(text=text)], + contents=agent_contents, role="assistant", author_name=name, ) return ResponseStream(_aiter(), finalizer=AgentResponse.from_updates) - inner = _TextAgent("text-agent", text) + inner = _TextAgent("text-agent", contents) @executor async def start(messages: list[Message], ctx: WorkflowContext[AgentExecutorRequest]) -> None: @@ -4585,6 +4818,96 @@ async def test_basic_text_response_streaming(self) -> None: text_done = [e for e in events if e["event"] == "response.output_text.done"] assert any(e["data"]["text"] == "hello stream" for e in text_done) + async def test_mid_run_consent_fails_the_workflow_response_as_not_resumable(self) -> None: + """A workflow that surfaces a consent request must emit the output item, then fail. + + Unlike the regular agent path, a consent-blocked workflow turn cannot be continued: + the ``AgentExecutor`` records a pending request that no input can answer, because + OAuth consent has no response content type. Reporting ``incomplete`` would promise a + continuation the workflow cannot honor, so the link is surfaced and the turn fails. + """ + workflow_agent = _build_contents_workflow_agent([ + Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + additional_properties={"server_label": "obo-mcp"}, + ) + ]) + server = _make_server(workflow_agent) + + resp = await _post(server, input_text="hi", stream=True) + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[-1] == "response.failed" + assert "response.incomplete" not in types + assert "response.completed" not in types + + added = [e for e in events if e["event"] == "response.output_item.added"] + oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"] + assert len(oauth_added) == 1 + assert oauth_added[0]["data"]["item"]["consent_link"] == "https://consent.example.com/obo" + assert oauth_added[0]["data"]["item"]["server_label"] == "obo-mcp" + + done = [e for e in events if e["event"] == "response.output_item.done"] + assert any(e["data"]["item"]["type"] == "oauth_consent_request" for e in done) + + # A WorkflowAgent replays the inner agent's content as workflow output, so the same + # consent request reaches the host twice and must not produce two consent prompts. + failed = [e for e in events if e["event"] == "response.failed"] + assert len(failed) == 1 + message = failed[0]["data"]["response"]["error"]["message"] + assert "1 tool(s)" in message + assert "cannot be resumed" in message + + async def test_second_turn_after_consent_reports_a_clear_failure(self) -> None: + """A conversation parked on consent must fail legibly on every later turn. + + The consent request is checkpointed as a pending ``request_info`` that no input can + answer, so restoring it and sending ordinary text would otherwise surface an internal + ``Unexpected content type while awaiting request info responses`` error. The restored + consent links are detected up front so the block is reported directly instead. + """ + workflow_agent = _build_contents_workflow_agent([ + Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + additional_properties={"server_label": "obo-mcp"}, + ) + ]) + server = _make_server(workflow_agent) + conversation_id = "conv-consent-resume" + + first = await _post(server, input_text="hi", stream=True, conversation_id=conversation_id) + assert _sse_event_types(_parse_sse_events(first.text))[-1] == "response.failed" + + second = await _post(server, input_text="thanks, granted", stream=True, conversation_id=conversation_id) + events = _parse_sse_events(second.text) + types = _sse_event_types(events) + + assert types[-1] == "response.failed" + assert "response.completed" not in types + message = [e for e in events if e["event"] == "response.failed"][0]["data"]["response"]["error"]["message"] + assert "cannot be resumed" in message + assert "Unexpected content type" not in message + + async def test_mid_run_unusable_consent_link_fails_the_workflow_response(self) -> None: + workflow_agent = _build_contents_workflow_agent([ + Content(type="oauth_consent_request", consent_link="https://[broken") + ]) + server = _make_server(workflow_agent) + + resp = await _post(server, input_text="hi", stream=True) + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + added = [e for e in events if e["event"] == "response.output_item.added"] + assert not any(e["data"]["item"]["type"] == "oauth_consent_request" for e in added) + # The workflow is blocked on consent with no link to show, so it must not report success. + assert types[-1] == "response.failed" + assert "response.completed" not in types + failed = [e for e in events if e["event"] == "response.failed"] + assert "OAuth consent is required" in failed[0]["data"]["response"]["error"]["message"] + async def test_cancellation_signal_stops_main_loop_and_completes(self) -> None: """Explicit-cancel: the workflow's main loop must break promptly and still complete.""" workflow_agent, inner = _build_multi_update_workflow_agent(["one", "two", "three"])